diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9dcc2e2a..e37e60e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,13 +21,18 @@ jobs: - uses: actions/checkout@v4 - {uses: gacts/github-slug@v1, id: slug} - id: values - run: echo "binary-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT + run: | + majorMinorPatch="${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}" + echo "version=${majorMinorPatch}" >> $GITHUB_OUTPUT + echo "binary-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT + echo "deb-name=webhook-tester_${majorMinorPatch}-1_${{ matrix.arch }}" >> $GITHUB_OUTPUT # build the frontend - uses: actions/setup-node@v4 - with: {node-version: 22, cache: 'npm', cache-dependency-path: ./web/package-lock.json} - - run: npm --prefix "$NPM_PREFIX" install --no-audit - - run: npm --prefix "$NPM_PREFIX" run generate - - run: npm --prefix "$NPM_PREFIX" run build + with: {node-version-file: ./web/package.json, cache: 'npm', cache-dependency-path: ./web/package-lock.json} + - run: | + npm --prefix "$NPM_PREFIX" install --no-audit + npm --prefix "$NPM_PREFIX" run generate + npm --prefix "$NPM_PREFIX" run build # build the backend - {uses: actions/setup-go@v5, with: {go-version-file: go.mod}} - run: go install "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1" @@ -45,6 +50,24 @@ jobs: file: ${{ steps.values.outputs.binary-name }} asset_name: ${{ steps.values.outputs.binary-name }} tag: ${{ github.ref }} + # build a Debian package + - if: matrix.os == 'linux' && matrix.arch == 'amd64' + env: + PKG_NAME: ${{ steps.values.outputs.deb-name }} + run: | + mkdir -p ${{ env.PKG_NAME }}/usr/local/bin ${{ env.PKG_NAME }}/DEBIAN + cp ${{ steps.values.outputs.binary-name }} ${{ env.PKG_NAME }}/usr/local/bin/webhook-tester + echo -e "Package: webhook-tester\nVersion: ${{ steps.values.outputs.version }}" > ${{ env.PKG_NAME }}/DEBIAN/control + echo -e "Architecture: ${{ matrix.arch }}\nMaintainer: ${{ github.actor }}" >> ${{ env.PKG_NAME }}/DEBIAN/control + echo -e "Description: Powerful tool for testing WebHooks and more" >> ${{ env.PKG_NAME }}/DEBIAN/control + dpkg-deb --build --root-owner-group ${{ env.PKG_NAME }} + - if: matrix.os == 'linux' && matrix.arch == 'amd64' + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ${{ steps.values.outputs.deb-name }}.deb + asset_name: ${{ steps.values.outputs.binary-name }}.deb + tag: ${{ github.ref }} build-docker-image: name: Build the docker image diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c7be9698..0e646b8a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,7 +42,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - with: {node-version: 22, cache: 'npm', cache-dependency-path: ./web/package-lock.json} + with: {node-version-file: ./web/package.json, cache: 'npm', cache-dependency-path: ./web/package-lock.json} - run: npm --prefix "$NPM_PREFIX" install -dd --no-audit - run: npm --prefix "$NPM_PREFIX" run generate - run: npm --prefix "$NPM_PREFIX" run lint @@ -63,13 +63,18 @@ jobs: - uses: actions/checkout@v4 - {uses: gacts/github-slug@v1, id: slug} - id: values - run: echo "binary-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT + run: | + majorMinorPatch="${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}" + echo "version=${majorMinorPatch}" >> $GITHUB_OUTPUT + echo "binary-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT + echo "deb-name=webhook-tester_${majorMinorPatch}-1_${{ matrix.arch }}" >> $GITHUB_OUTPUT # build the frontend - uses: actions/setup-node@v4 - with: {node-version: 22, cache: 'npm', cache-dependency-path: ./web/package-lock.json} - - run: npm --prefix "$NPM_PREFIX" install --no-audit - - run: npm --prefix "$NPM_PREFIX" run generate - - run: npm --prefix "$NPM_PREFIX" run build + with: {node-version-file: ./web/package.json, cache: 'npm', cache-dependency-path: ./web/package-lock.json} + - run: | + npm --prefix "$NPM_PREFIX" install --no-audit + npm --prefix "$NPM_PREFIX" run generate + npm --prefix "$NPM_PREFIX" run build # build the backend - {uses: actions/setup-go@v5, with: {go-version-file: go.mod}} - run: go install "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1" @@ -78,17 +83,63 @@ jobs: GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} CGO_ENABLED: 0 - LDFLAGS: -s -w -X gh.tarampamp.am/webhook-tester/v2/internal/version.version=v0.0.0@${{ steps.slug.outputs.commit-hash-short }} + LDFLAGS: -s -w -X gh.tarampamp.am/webhook-tester/v2/internal/version.version=v${{ steps.values.outputs.version }}@${{ steps.slug.outputs.commit-hash-short }} run: go build -trimpath -ldflags "$LDFLAGS" -o ./${{ steps.values.outputs.binary-name }} ./cmd/webhook-tester/ - # try to run the binary - - if: matrix.os == runner.os && matrix.arch == 'amd64' + - if: matrix.os == runner.os && matrix.arch == 'amd64' # try to run the binary run: ./${{ steps.values.outputs.binary-name }} -h + # upload the binary - uses: actions/upload-artifact@v4 with: name: ${{ steps.values.outputs.binary-name }} path: ./${{ steps.values.outputs.binary-name }} if-no-files-found: error retention-days: 7 + # build a Debian package + - if: matrix.os == 'linux' && matrix.arch == 'amd64' + env: + PKG_NAME: ${{ steps.values.outputs.deb-name }} + run: | + mkdir -p ${{ env.PKG_NAME }}/usr/local/bin ${{ env.PKG_NAME }}/DEBIAN + cp ${{ steps.values.outputs.binary-name }} ${{ env.PKG_NAME }}/usr/local/bin/webhook-tester + echo -e "Package: webhook-tester\nVersion: ${{ steps.values.outputs.version }}" > ${{ env.PKG_NAME }}/DEBIAN/control + echo -e "Architecture: ${{ matrix.arch }}\nMaintainer: ${{ github.actor }}" >> ${{ env.PKG_NAME }}/DEBIAN/control + echo -e "Description: Powerful tool for testing WebHooks and more" >> ${{ env.PKG_NAME }}/DEBIAN/control + dpkg-deb --build --root-owner-group ${{ env.PKG_NAME }} + - if: matrix.os == 'linux' && matrix.arch == 'amd64' + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.values.outputs.binary-name }}.deb + path: ./${{ steps.values.outputs.deb-name }}.deb + if-no-files-found: error + retention-days: 7 + + e2e-test: + name: End-to-end tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + storage-driver: [memory, redis] + pubsub-driver: [memory, redis] + services: + redis: + image: docker.io/library/redis:7-alpine + ports: ['6379:6379/tcp'] + options: --health-cmd "redis-cli ping" --health-interval 3s --health-timeout 2s --health-retries 3 + needs: [build-app] + steps: + - uses: actions/checkout@v4 + - {uses: actions/download-artifact@v4, with: {name: webhook-tester-linux-amd64}} + - run: | # start the server in the background + chmod +x ./webhook-tester-linux-amd64 + ./webhook-tester-linux-amd64 start --port 8081 \ + --storage-driver "${{ matrix.storage-driver }}" \ + --pubsub-driver "${{ matrix.pubsub-driver }}" \ + --redis-dsn "redis://127.0.0.1:6379/0" & + - uses: grafana/setup-k6-action@v1 + - uses: grafana/run-k6-action@v1 + env: {BASE_URL: 'http://127.0.0.1:8081'} + with: {path: ./tests/k6/run.js} build-docker-image: name: Build the docker image diff --git a/Makefile b/Makefile index 7b48125d..60d9836f 100644 --- a/Makefile +++ b/Makefile @@ -27,11 +27,12 @@ node-fmt: ## Format frontend code lint: ## Run linters docker compose run $(DC_RUN_ARGS) app golangci-lint run +e2e: ## Run end-to-end tests + docker compose run $(DC_RUN_ARGS) k6 run ./tests/k6/run.js + up: ## Start the application in watch mode #docker compose build - docker compose up --detach --build --remove-orphans docker compose kill app-http --remove-orphans 2>/dev/null || true - #docker compose up --detach --wait whoami httpbin docker compose up app-http down: ## Stop the application diff --git a/README.md b/README.md index 6d2cc490..598b42d4 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ The following flags are supported: | Name | Description | Default value | Environment variables | |-------------------------------|---------------------------------------------------------------------------------------------------------------------------|:--------------------------:|:----------------------------:| | `--addr="…"` | IP (v4 or v6) address to listen on (0.0.0.0 to bind to all interfaces) | `0.0.0.0` | `SERVER_ADDR`, `LISTEN_ADDR` | -| `--http-port="…"` | HTTP server port | `8080` | `HTTP_PORT` | +| `--port="…"` | HTTP server port | `8080` | `HTTP_PORT` | | `--read-timeout="…"` | maximum duration for reading the entire request, including the body (zero = no timeout) | `1m0s` | `HTTP_READ_TIMEOUT` | | `--write-timeout="…"` | maximum duration before timing out writes of the response (zero = no timeout) | `1m0s` | `HTTP_WRITE_TIMEOUT` | | `--idle-timeout="…"` | maximum amount of time to wait for the next request (keep-alive, zero = no timeout) | `1m0s` | `HTTP_IDLE_TIMEOUT` | @@ -146,9 +146,9 @@ $ app [GLOBAL FLAGS] start healthcheck [COMMAND FLAGS] [ARGUMENTS...] The following flags are supported: -| Name | Description | Default value | Environment variables | -|-------------------|------------------|:-------------:|:---------------------:| -| `--http-port="…"` | HTTP server port | `8080` | `HTTP_PORT` | +| Name | Description | Default value | Environment variables | +|--------------|------------------|:-------------:|:---------------------:| +| `--port="…"` | HTTP server port | `8080` | `HTTP_PORT` | <!--/GENERATED:CLI_DOCS--> diff --git a/api/openapi.yml b/api/openapi.yml index 9a39ddcc..9fcf40f8 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -1,5 +1,3 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/schemas/v3.0/schema.json - openapi: 3.0.3 info: title: WebHook Tester @@ -186,7 +184,7 @@ components: description: HTTP status code type: integer example: 301 - minimum: 100 + minimum: 200 maximum: 530 Base64Encoded: diff --git a/compose.yml b/compose.yml index 809ccdde..08f47563 100644 --- a/compose.yml +++ b/compose.yml @@ -20,12 +20,28 @@ services: app-http: build: *app-build - command: go run ./cmd/webhook-tester/ start --use-live-frontend --auto-create-sessions --max-requests 8 + entrypoint: sh -c 'go build -buildvcs=false -o /var/tmp/app ./cmd/webhook-tester/ && exec $0 "$@"' + command: /var/tmp/app start --use-live-frontend --auto-create-sessions --max-requests 8 volumes: [.:/src:rw] ports: ['8080:8080/tcp'] + healthcheck: + test: ['CMD', '/var/tmp/app', 'start', 'healthcheck'] + start_interval: 1s + interval: 10s + start_period: 10s depends_on: {app-web-dist: {condition: service_healthy}} security_opt: [no-new-privileges:true] + k6: + image: ghcr.io/grafana/k6:latest + volumes: [.:/src:ro] + working_dir: /src + environment: + BASE_URL: http://app-http:8080 + K6_NO_USAGE_REPORT: 'true' + depends_on: {app-http: {condition: service_healthy}} + security_opt: [no-new-privileges:true] + volumes: app-modules-cache: {} app-tmp-data: {} diff --git a/internal/cli/start/command.go b/internal/cli/start/command.go index 0258266e..c7fa2e2f 100644 --- a/internal/cli/start/command.go +++ b/internal/cli/start/command.go @@ -92,7 +92,7 @@ func NewCommand(log *zap.Logger, defaultHttpPort uint16) *cli.Command { //nolint }, } httpPortFlag = cli.UintFlag{ - Name: "http-port", + Name: "port", Category: httpCategory, Usage: "HTTP server port", Value: uint64(defaultHttpPort), diff --git a/internal/cli/start/healthcheck/command.go b/internal/cli/start/healthcheck/command.go index b2435d4e..f71078a9 100644 --- a/internal/cli/start/healthcheck/command.go +++ b/internal/cli/start/healthcheck/command.go @@ -11,7 +11,7 @@ import ( func NewCommand(defaultHttpPort uint16) *cli.Command { var ( httpPortFlag = cli.UintFlag{ - Name: "http-port", + Name: "port", Usage: "HTTP server port", Value: uint64(defaultHttpPort), Sources: cli.EnvVars("HTTP_PORT"), diff --git a/internal/http/middleware/webhook/middleware.go b/internal/http/middleware/webhook/middleware.go index f6db6d88..a995abbe 100644 --- a/internal/http/middleware/webhook/middleware.go +++ b/internal/http/middleware/webhook/middleware.go @@ -127,6 +127,8 @@ func New( //nolint:funlen,gocognit,gocyclo return } + w.Header().Set("X-Wh-Request-Id", rID) + // publish the captured request to the pub/sub. important note - we should use the app ctx instead of the req ctx // because the request context can be canceled before the goroutine finishes (and moreover - before the // subscribers will receive the event - in this case the event will be lost) diff --git a/internal/http/openapi/models_validate.go b/internal/http/openapi/models_validate.go index 41a7c07b..658d41a0 100644 --- a/internal/http/openapi/models_validate.go +++ b/internal/http/openapi/models_validate.go @@ -14,7 +14,7 @@ func (data CreateSessionRequest) Validate() error { minHeaderKeyLen, maxHeaderKeyLen = 1, 40 maxHeaderValueLen = 2048 maxResponseBodyLen = 10240 - minStatusCode, maxStatusCode = StatusCode(100), StatusCode(530) + minStatusCode, maxStatusCode = StatusCode(200), StatusCode(530) ) if data.Delay > maxDelaySeconds { diff --git a/tests/k6/run.js b/tests/k6/run.js new file mode 100644 index 00000000..7a65941c --- /dev/null +++ b/tests/k6/run.js @@ -0,0 +1,310 @@ +import http from 'k6/http' +import {b64decode, b64encode} from 'k6/encoding' +import {check, group} from './shared/k6.js' +import {randomString} from './shared/utils.js' +import {isArraysEqual, isJson, isTimestampMillisInDelta, isUUID} from './shared/checks.js' + +/** @link https://grafana.com/docs/k6/latest/using-k6/k6-options/reference/ */ +export const options = { + scenarios: { + default: { + executor: 'per-vu-iterations', + vus: 1, // force to use only one VU + }, + }, +} + +/** @typedef {{baseUrl: String}} Context */ +/** @return Context */ +export const setup = () => { + const baseUrl = __ENV['BASE_URL'] + + if (!baseUrl) { + throw new Error('BASE_URL is required') + } + + return { + baseUrl: baseUrl.replace(/\/$/, ''), // remove trailing slash + } +} + +/** @param {Context} ctx */ +export default (ctx) => { + const {baseUrl} = ctx + + group('spa', () => { + testSpaIndex(baseUrl) + testSpaNotFound(baseUrl) + testSpaRobots(baseUrl) + }) + + group('api', () => { + testApiNotFound(baseUrl) + testApiReady(baseUrl) + testApiSettings(baseUrl) + + group('session', () => { + testApiCreateSessionNegative(baseUrl) + testApiSessionGetNegative(baseUrl) + + for (let statusCode = 200; statusCode <= 530; statusCode++) { // all possible status codes + const [headerName, headerValue] = ['X-Custom' + randomString(10).toLowerCase(), randomString(10)] + const responseBody = JSON.stringify({foo: randomString(10)}) + + const sID = testApiCreateSession(baseUrl, statusCode, [{name: headerName, value: headerValue}], responseBody) + + testApiSessionGet(baseUrl, sID, {statusCode, headers: [{name: headerName, value: headerValue}], responseBody}) + + group('requests', () => { + testApiSessionHasNoRequests(baseUrl, sID) // initially, there are no requests + testApiSessionCatchRequest(baseUrl, sID, { + wantStatus: statusCode, + wantHeaders: [{name: headerName, value: headerValue}], + wantBody: responseBody, + }) + }) + + testApiSessionDelete(baseUrl, sID) + } + }) + }) +} + +/** @param {String} baseUrl */ +const testSpaIndex = (baseUrl) => group('index', () => + check(http.get(baseUrl), { + 'status is 200': (r) => r.status === 200, + 'content type': (r) => r.headers['Content-Type'].includes('text/html'), + 'contains HTML': (r) => r.body.includes('<html'), + }) +) + +/** @param {String} baseUrl */ +const testSpaNotFound = (baseUrl) => group('404', () => + check(http.get(`${baseUrl}/foo${randomString(10)}`), { + 'status is 200': (r) => r.status === 200, + 'content type': (r) => r.headers['Content-Type'].includes('text/html'), + 'contains HTML': (r) => r.body.includes('<html'), + }) +) + +/** @param {String} baseUrl */ +const testSpaRobots = (baseUrl) => group('robots.txt', () => + check(http.get(`${baseUrl}/robots.txt`), { + 'status is 200': (r) => r.status === 200, + 'content type': (r) => r.headers['Content-Type'].includes('text/plain'), + 'contains useragent': (r) => r.body.includes('User-agent'), + 'contains disallow': (r) => r.body.includes('Disallow'), + }) +) + +/** @param {String} baseUrl */ +const testApiNotFound = (baseUrl) => group('404', () => + check(http.get(`${baseUrl}/////api/foo${randomString(10)}`), { + 'status is 404': (r) => r.status === 404, + 'is json': (r) => isJson(r), + 'contains error': (r) => r.body.includes('error'), + }) +) + +/** @param {String} baseUrl */ +const testApiReady = (baseUrl) => group('ready', () => + check(http.get(`${baseUrl}/ready`), { + 'status is 200': (r) => r.status === 200, + 'content type': (r) => r.headers['Content-Type'].includes('text/plain'), + 'contains ready': (r) => r.body.includes('OK'), + }) +) + +/** @param {String} baseUrl */ +const testApiSettings = (baseUrl) => group('settings', () => + check(http.get(`${baseUrl}/api/settings`), { + 'status is 200': (r) => r.status === 200, + 'is json': (r) => isJson(r), + // TODO: add object properties checks + }) +) + +/** + * @param {String} baseUrl + * @param {Number} statusCode + * @param {Array<{name: String, value: String}>} headers + * @param {String} respBody + * @return {String} session ID + */ +const testApiCreateSession = (baseUrl, statusCode, headers, respBody) => group('create', () => { + const resp = http.post(`${baseUrl}/api/session`, JSON.stringify({ // create a new session + status_code: statusCode, + headers: headers, + delay: 0, + response_body_base64: b64encode(respBody), + })) + + check(resp, { + 'status is 200': (r) => r.status === 200, + 'is json': (r) => isJson(r), + 'uuid is not empty': (r) => isUUID(r.json('uuid')), + 'created_at is not empty': (r) => isTimestampMillisInDelta(r.json('created_at_unix_milli'), 2_000), + 'response status code is expected': (r) => r.json('response.status_code') === statusCode, + 'response headers': (r) => isArraysEqual(r.json('response.headers'), headers), + 'response delay': (r) => r.json('response.delay') === 0, // TODO: add test with delay + 'response response body (base64)': (r) => r.json('response.response_body_base64') === b64encode(respBody), + }) + + return resp.json('uuid') +}) + +/** @param {String} baseUrl */ +const testApiCreateSessionNegative = (baseUrl) => group('create', () => { + for (const [name, [wantErrSubstr, payload]] of Object.entries({ + 'too small status code': ['wrong status code', {status_code: 99, headers: [], delay: 0, response_body_base64: ''}], + 'too big status code': ['wrong status code', {status_code: 531, headers: [], delay: 0, response_body_base64: ''}], + 'invalid header name': ['header key length', { + status_code: 200, + headers: [{name: '', value: 'bar'}], + delay: 0, + response_body_base64: '' + }], + 'invalid header value': ['header value length', { + status_code: 200, + headers: [{name: 'foo', value: 'x'.repeat(2049)}], + delay: 0, + response_body_base64: '' + }], + 'negative delay': ['delay', {status_code: 200, headers: [], delay: -1, response_body_base64: ''}], + 'too big delay': ['delay', {status_code: 200, headers: [], delay: 31, response_body_base64: ''}], + 'invalid base64': ['cannot decode response body', { + status_code: 200, + headers: [], + delay: 0, + response_body_base64: 'foobar' + }], + })) { + group(name, () => + check(http.post(`${baseUrl}/api/session`, JSON.stringify(payload)), { + 'status is 400': (r) => r.status === 400, + 'is json': (r) => isJson(r), + 'contains error': (r) => r.body.includes(wantErrSubstr), + }) + ) + } +}) + +/** + * @param {String} baseUrl + * @param {String} sID + * @param {{statusCode: Number, headers: Array<{name: String, value: String}>, responseBody: string}} want + */ +const testApiSessionGet = (baseUrl, sID, want) => group('create', () => + check(http.get(`${baseUrl}/api/session/${sID}`), { + 'status is 200': (r) => r.status === 200, + 'is json': (r) => isJson(r), + 'uuid is not empty': (r) => isUUID(r.json('uuid')), + 'created_at is not empty': (r) => isTimestampMillisInDelta(r.json('created_at_unix_milli'), 2_000), + 'response status code': (r) => r.json('response.status_code') === want.statusCode, + 'response headers': (r) => isArraysEqual(r.json('response.headers'), want.headers), + 'response delay': (r) => r.json('response.delay') === 0, + 'response response body (base64)': (r) => r.json('response.response_body_base64') === b64encode(want.responseBody), + }) +) + +/** @param {String} baseUrl */ +const testApiSessionGetNegative = (baseUrl) => group('negative', () => { + group('not found', () => { + check(http.get(`${baseUrl}/api/session/00000000-0000-0000-0000-000000000000`), { + 'status is 404': (r) => r.status === 404, + 'is json': (r) => isJson(r), + 'contains error': (r) => r.body.includes('session not found'), + }) + }) + + group('wrong session ID format', () => { + check(http.get(`${baseUrl}/api/session/foobar`), { + 'status is 400': (r) => r.status === 400, + 'is json': (r) => isJson(r), + 'contains error': (r) => r.body.includes('invalid UUID format'), + }) + }) +}) + +/** + * @param {String} baseUrl + * @param {String} sID + */ +const testApiSessionHasNoRequests = (baseUrl, sID) => group('requests', () => + check(http.get(`${baseUrl}/api/session/${sID}/requests`), { + 'status is 200': (r) => r.status === 200, + 'is json': (r) => isJson(r), + 'is array': (r) => Array.isArray(r.json()), + 'is empty': (r) => r.json().length === 0, + }) +) + +/** + * @param {String} baseUrl + * @param {String} sID + * @param {{wantStatus: Number, wantHeaders: Array<{name: String, value: String}>, wantBody: String}} want + */ +const testApiSessionCatchRequest = (baseUrl, sID, {wantStatus, wantHeaders, wantBody}) => group('catch', () => { + for (const path of ['', '/foo', '////bar////////baz?yes=no']) { + for (const payload of ['', 'foobar'.repeat(100)]) { + for (const method of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']) { + const [headerName, headerValue] = ['X-Custom' + randomString(10).toLowerCase(), randomString(10)] + + const resp = http.request(method, `${baseUrl}/${sID}${path}`, payload, { // send a request + headers: {[headerName]: headerValue}, + tags: {sid: sID}, + }) + + const responseMustBeEmpty = wantStatus === 204 || wantStatus === 304 // 204 = No Content, 304 = Not Modified + const rID = resp.headers['X-Wh-Request-Id'] + + check(resp, { + 'request id (from headers) is not empty': () => rID.length > 0, + 'response status': (r) => r.status === wantStatus, + 'response body is expected': (r) => r.body === (responseMustBeEmpty ? null : wantBody), + 'response headers are expected': (r) => wantHeaders.every(({name, value}) => r.headers[name] === value), + 'cors headers are expected': (r) => ( + r.headers['Access-Control-Allow-Origin'] === '*' && + r.headers['Access-Control-Allow-Methods'] === '*' && + r.headers['Access-Control-Allow-Headers'] === '*' + ), + }) + + check(http.get(`${baseUrl}/api/session/${sID}/requests/${rID}`).json(), { + 'last request uuid': (r) => isUUID(r.uuid), + 'last request client address': (r) => r.client_address.length > 0, + 'last request method': (r) => r.method === method, + 'last request payload': (r) => b64decode(r.request_payload_base64, 'std', 's') === payload, + 'last request headers': (r) => r.headers.some(({ + name, + value + }) => name === headerName && value === headerValue), + 'last request url': (r) => r.url.endsWith(`/${sID}${path}`), + }) + } + } + } +}) + +/** + * @param {String} baseUrl + * @param {String} sID + */ +const testApiSessionDelete = (baseUrl, sID) => group('delete', () => { + // ensure that the session exists first + check(http.get(`${baseUrl}/api/session/${sID}`), {'status is 200': (r) => r.status === 200}) + + // delete the session + check(http.del(`${baseUrl}/api/session/${sID}`), { + 'status is 200': (r) => r.status === 200, + 'is json': (r) => isJson(r), + 'success': (r) => r.json('success') === true, + }) + + // try to get it back + check(http.get(`${baseUrl}/api/session/${sID}`), {'status is 404': (r) => r.status === 404}) + + // try to delete it again + check(http.del(`${baseUrl}/api/session/${sID}`), {'status is 404': (r) => r.status === 404}) +}) diff --git a/tests/k6/shared/checks.js b/tests/k6/shared/checks.js new file mode 100644 index 00000000..d68fb286 --- /dev/null +++ b/tests/k6/shared/checks.js @@ -0,0 +1,106 @@ +/** + * Check if the response is a JSON object and can be parsed. + * + * @return {boolean} + */ +export const isJson = (r) => { + if (!r.headers['Content-Type'].includes('application/json')) { + return false + } + + try { + JSON.parse(r.body) + } catch (e) { + return false + } + + return true +} + +/** + * Checks if arrays are equal. + * + * @param {Array<*>} a + * @param {Array<*>} b + * @return {boolean} + */ +export const isArraysEqual = (a, b) => { + return a.length === b.length && a.every((v, i) => { + if (Array.isArray(v) && Array.isArray(b[i])) { + return isArraysEqual(v, b) + } + + if (typeof v === 'object' && typeof b[i] === 'object') { + return deepEqual(v, b[i]) + } + + return v === b[i] + }) +} + +/** + * @param {Object} obj1 + * @param {Object} obj2 + * @return {boolean} + */ +function deepEqual(obj1, obj2) { + if (obj1 === obj2) { + return true + } + + if (typeof obj1 !== 'object' || obj1 === null || typeof obj2 !== 'object' || obj2 === null) { + return false + } + + const keys1 = Object.keys(obj1) + const keys2 = Object.keys(obj2) + + if (keys1.length !== keys2.length) { + return false + } + + for (const key of keys1) { + if (!obj2.hasOwnProperty(key)) { + return false + } + + if (!deepEqual(obj1[key], obj2[key])) { + return false + } + } + + return true +} + +/** + * Check if the string is a UUID and not the nil UUID. + * + * @param {String} uuid + * @return {boolean} + */ +export const isUUID = (uuid) => { + if (typeof uuid !== 'string') { + return false + } + + if (uuid.length !== 36) { + return false + } + + if (!uuid.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) { + return false + } + + return uuid !== '00000000-0000-0000-0000-000000000000'; +} + +/** + * Check if the timestamp is within the delta. + * + * @param {Number} timestamp + * @param {Number} delta + * @return {boolean} + */ +export const isTimestampMillisInDelta = (timestamp, delta) => { + return timestamp > new Date().getTime() - delta +} diff --git a/tests/k6/shared/k6.js b/tests/k6/shared/k6.js new file mode 100644 index 00000000..486d5a8e --- /dev/null +++ b/tests/k6/shared/k6.js @@ -0,0 +1,43 @@ +import {check as k6check, group as k6group} from 'k6' +import execution from 'k6/execution' + +/** + * This is an overridden version of the native check function, designed to abort the test when an expectation fails. + * In addition, it improves the type checking by using the JSDoc annotation. + * + * @template VT + * @param {VT} val + * @param {Record<string, (VT) => Boolean>} set + * @returns {boolean} + */ +export const check = (val, set) => { + try { + for (const [key, fn] of Object.entries(set)) { + if (!k6check(val, { [key]: fn })) { + execution.test.abort(`Failed expectation: ${key}`) + } + } + } catch (e) { + execution.test.abort(String(e)) + + throw e + } +} + +/** + * This is an overridden version of the native group function, designed to abort the test when an error occurs. + * + * @template RT + * @param {String} name + * @param {() => RT} fn + * @returns {RT} + */ +export const group = (name, fn) => { + try { + return k6group(name, fn) + } catch (e) { + execution.test.abort(String(e)) + + throw e + } +} diff --git a/tests/k6/shared/utils.js b/tests/k6/shared/utils.js new file mode 100644 index 00000000..54681292 --- /dev/null +++ b/tests/k6/shared/utils.js @@ -0,0 +1,38 @@ +/** + * Returns a random integer between min (inclusive) and max (inclusive). + * + * @param {Number} min The minimum value + * @param {Number} max The maximum value + * @returns {Number} The random integer + */ +export const randomIntBetween = (min, max) => Math.floor(Math.random() * (max - min + 1) + min) + +/** + * Returns a random element from the array. + * + * @template T + * @param {Array<T>} array The array + * @returns {T} The random element + */ +export const randomArrayElement = (array) => array[randomIntBetween(0, array.length - 1)] + +/** + * Generates a random string of the specified length. + * + * @param {Number} length The length of the string + * @param {String} [charset] The charset to use + * @returns {String} The random string + */ +export const randomString = (length, charset) => { + let res = '' + + if (!charset) { + charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + } + + while (length--) { + res += charset[(Math.random() * charset.length) | 0] + } + + return res +} diff --git a/web/package.json b/web/package.json index 0ae6b9cc..dafb81a3 100644 --- a/web/package.json +++ b/web/package.json @@ -52,7 +52,10 @@ "vitest": "^2.1.3", "vitest-fetch-mock": "^0.3.0" }, + "volta": { + "node": "22" + }, "engines": { - "node": ">=21" + "node": ">=22" } } diff --git a/web/src/screens/components/header-new-session-modal.tsx b/web/src/screens/components/header-new-session-modal.tsx index afc6bb45..f2908a6f 100644 --- a/web/src/screens/components/header-new-session-modal.tsx +++ b/web/src/screens/components/header-new-session-modal.tsx @@ -4,7 +4,7 @@ import { IconCodeAsterisk, IconHeading, IconHourglassHigh, IconVersions } from ' import { useStorage, UsedStorageKeys } from '~/shared' const limits = { - statusCode: { min: 100, max: 530 }, + statusCode: { min: 200, max: 530 }, responseHeaders: { maxCount: 10, minNameLen: 1, maxNameLen: 40, maxValueLen: 2048 }, delay: { min: 0, max: 30 }, }