From dc9a498418b206966f054d8da6cc752ddd7317d5 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 01:27:50 +0100 Subject: [PATCH 01/14] chore(core): implement spec 0.2.0 Spec 0.2.0 adds the client contract and the fixtures that the mock server reads. Its grammar and vectors are the same as before, so the core passes them unchanged and only its SPEC_VERSION moves. CONTEXT.md takes the new terms of the spec's glossary. Signed-off-by: MobileMage --- .changeset/core-spec-0-2-0.md | 5 +++++ CONTEXT.md | 14 +++++++++++++- packages/core/README.md | 6 +++--- packages/core/etc/core.api.md | 2 +- packages/core/src/version.ts | 4 ++-- spec | 2 +- 6 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 .changeset/core-spec-0-2-0.md diff --git a/.changeset/core-spec-0-2-0.md b/.changeset/core-spec-0-2-0.md new file mode 100644 index 0000000..ae63db9 --- /dev/null +++ b/.changeset/core-spec-0-2-0.md @@ -0,0 +1,5 @@ +--- +'@gatepost/core': patch +--- + +The package implements spec 0.2.0. That version adds the client contract and leaves the grammar as it was, so every function gives the same results as before. diff --git a/CONTEXT.md b/CONTEXT.md index 64a0f59..fcc8182 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -122,9 +122,21 @@ _Avoid_: using this word for a Gatepost component ### Gatepost **Spec**: -The `spec` repo. It holds the grammar, the data, the vectors and the standards. The API fixtures and the completed OpenAPI file come later. +The `spec` repo. It holds the grammar, the client contract, the data, the vectors, the fixtures, the completed OpenAPI file and the standards. _Avoid_: schema, contract (for the repo) +**Contract scenario**: +One shared test case in `spec/contract/`. It names the calls, the mock server's responses and the outcome that every client must reach. +_Avoid_: contract test (for the file), vector, fixture + +**Attempt**: +One request that a client sends for one call. A retry is the second or third attempt. +_Avoid_: try, request (when you count them) + +**Evidence**: +How Gatepost knows the shape of a gateway response: `observed` in its own calls, `documented` by NIPOST only, or `assumed` by the mock server. +_Avoid_: source, confidence, proof + **Vector**: One shared test case in `spec/vectors/` that every SDK must pass. _Avoid_: fixture, example, golden file diff --git a/packages/core/README.md b/packages/core/README.md index c6e2188..6ea4ccf 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -97,7 +97,7 @@ parse(code.padEnd(65)).ok; // false ### Clean typed text -`normalize` cleans text that a user typed or pasted. It applies Unicode NFKC, removes white space, hyphens, dashes, full stops and some zero-width characters, and changes ASCII letters to upper case. It does not check the result. It has no input limit, so call `parse` for text from an untrusted source. The result depends on the Unicode version of the runtime. Spec 0.1.0 names Unicode 17.0, which Node 22.22.2 and later 22.x releases have, and so do Node 24.15.0 and later. +`normalize` cleans text that a user typed or pasted. It applies Unicode NFKC, removes white space, hyphens, dashes, full stops and some zero-width characters, and changes ASCII letters to upper case. It does not check the result. It has no input limit, so call `parse` for text from an untrusted source. The result depends on the Unicode version of the runtime. The spec names Unicode 17.0, which Node 22.22.2 and later 22.x releases have, and so do Node 24.15.0 and later. ```ts import { normalize } from '@gatepost/core'; @@ -180,14 +180,14 @@ if (building.ok) { | ------------- | ----------------------------------- | | Node | 22 or later, or any current browser | | Module format | ESM only | -| Gatepost spec | 0.1.0 | +| Gatepost spec | 0.2.0 | The package exports `SPEC_VERSION`, the version of the Gatepost spec that it implements. ```ts import { SPEC_VERSION } from '@gatepost/core'; -SPEC_VERSION; // '0.1.0' +SPEC_VERSION; // '0.2.0' ``` ## Docs diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 37eab32..ef7b226 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -68,7 +68,7 @@ export interface Segments { } // @public -export const SPEC_VERSION = "0.1.0"; +export const SPEC_VERSION = "0.2.0"; // @public export function stateName(stateCode: string): string | null; diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index 9a6a64b..ab60c2f 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -8,7 +8,7 @@ * ```ts * import { SPEC_VERSION } from '@gatepost/core'; * - * SPEC_VERSION; // '0.1.0' + * SPEC_VERSION; // '0.2.0' * ``` */ -export const SPEC_VERSION = '0.1.0'; +export const SPEC_VERSION = '0.2.0'; diff --git a/spec b/spec index ca4afdd..8ab969f 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit ca4afddf65e2897c71bb49ef7bed71b1a9741408 +Subproject commit 8ab969fcc34cf1ef0e54b01e0fe07bac217de8f4 From 26844401be20633ed58b784746caec2e1c6dab04 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 01:30:34 +0100 Subject: [PATCH 02/14] feat(mock): read the fixtures, keys and scenarios from the spec The mock server answers from the spec's files, so a new observation changes data and no code. It reads them once at start and stops with the name of a file that has the wrong shape, so a broken spec change cannot serve a wrong body in silence. The package runs on Node, so the lint config lets it import Node modules, and a test pins that exception. Signed-off-by: MobileMage --- .changeset/config.json | 3 +- commitlint.config.js | 6 +- eslint.config.js | 9 + packages/mock-server/package.json | 19 ++ packages/mock-server/src/reply.ts | 55 ++++ packages/mock-server/src/spec-files.ts | 252 +++++++++++++++++++ packages/mock-server/test/mock-request.ts | 64 +++++ packages/mock-server/test/spec-files.test.ts | 128 ++++++++++ packages/mock-server/tsconfig.json | 9 + packages/mock-server/vitest.config.ts | 16 ++ scripts/eslint-mock-server.test.mjs | 56 +++++ 11 files changed, 615 insertions(+), 2 deletions(-) create mode 100644 packages/mock-server/package.json create mode 100644 packages/mock-server/src/reply.ts create mode 100644 packages/mock-server/src/spec-files.ts create mode 100644 packages/mock-server/test/mock-request.ts create mode 100644 packages/mock-server/test/spec-files.test.ts create mode 100644 packages/mock-server/tsconfig.json create mode 100644 packages/mock-server/vitest.config.ts create mode 100644 scripts/eslint-mock-server.test.mjs diff --git a/.changeset/config.json b/.changeset/config.json index ad9ea29..3692d4d 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,5 +8,6 @@ "ignore": [], "fixed": [], "linked": [], - "updateInternalDependencies": "patch" + "updateInternalDependencies": "patch", + "privatePackages": { "version": false, "tag": false } } diff --git a/commitlint.config.js b/commitlint.config.js index d224421..30febe4 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -5,7 +5,11 @@ export default { rules: { 'header-max-length': [2, 'always', 72], 'scope-empty': [2, 'never'], - 'scope-enum': [2, 'always', ['core', 'client', 'field', 'react', 'repo', 'deps', 'release']], + 'scope-enum': [ + 2, + 'always', + ['core', 'client', 'field', 'react', 'mock', 'repo', 'deps', 'release'], + ], 'signed-off-by': [2, 'always', 'Signed-off-by:'], }, }; diff --git a/eslint.config.js b/eslint.config.js index 99d4094..31dec61 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -119,6 +119,15 @@ export default defineConfig( ], }, }, + { + // The mock server is a dev tool that runs on Node, not core code (CS-2), so it can use Node. + files: ['packages/mock-server/src/**/*.ts'], + rules: { + 'no-restricted-imports': 'off', + 'no-restricted-globals': 'off', + 'no-restricted-properties': 'off', + }, + }, { files: ['packages/*/test/**/*.ts'], rules: { diff --git a/packages/mock-server/package.json b/packages/mock-server/package.json new file mode 100644 index 0000000..6775566 --- /dev/null +++ b/packages/mock-server/package.json @@ -0,0 +1,19 @@ +{ + "name": "@gatepost/mock-server", + "version": "0.1.0", + "private": true, + "description": "Unofficial. Not made or endorsed by NIPOST. A mock of NIPOST's gateway.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" + }, + "scripts": { + "start": "node src/main.ts", + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run --coverage" + } +} diff --git a/packages/mock-server/src/reply.ts b/packages/mock-server/src/reply.ts new file mode 100644 index 0000000..666bef1 --- /dev/null +++ b/packages/mock-server/src/reply.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +/** A request as the mock server reads it. Header names are in lower case. */ +export interface MockRequest { + readonly method: string; + readonly path: string; + readonly query: URLSearchParams; + readonly headers: Readonly>; + readonly body: string; +} + +/** + * What the mock server does with a request: send a response, keep the connection open with no + * answer, or close the connection with no answer. `delayMs` is the wait before it acts. + */ +export type Reply = + | { + readonly kind: 'send'; + readonly status: number; + readonly headers: Readonly>; + readonly body: string; + readonly delayMs: number; + } + | { readonly kind: 'hang' } + | { readonly kind: 'drop'; readonly delayMs: number }; + +/** A reply that sends a response. */ +export type SendReply = Extract; + +const JSON_TYPE = { 'Content-Type': 'application/json' }; + +/** + * Builds a reply with a JSON body. The gateway ends each body with a line feed, and so does + * this reply. + * + * @param status - The HTTP status. + * @param body - The value to send as JSON. + * @param headers - More response headers. + * @returns The reply, with no delay. + * @internal + */ +export function jsonReply( + status: number, + body: unknown, + headers: Readonly> = {}, +): SendReply { + return { + kind: 'send', + status, + headers: { ...JSON_TYPE, ...headers }, + body: `${JSON.stringify(body)}\n`, + delayMs: 0, + }; +} diff --git a/packages/mock-server/src/spec-files.ts b/packages/mock-server/src/spec-files.ts new file mode 100644 index 0000000..d423888 --- /dev/null +++ b/packages/mock-server/src/spec-files.ts @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { jsonReply, type Reply } from './reply.ts'; + +/** One gateway response from `spec/fixtures`. */ +export interface Fixture { + readonly status: number; + readonly body: unknown; +} + +/** One key from `spec/fixtures/keys.json`, and the way that the mock server treats it. */ +export interface MockKey { + readonly key: string; + readonly lookupLevel: number; + readonly lookupScope: boolean; + readonly credits: boolean; + readonly rateLimited: boolean; + readonly origins: readonly string[] | null; +} + +/** One segment of a postcode, with its place in the compact form. */ +export interface SegmentRule { + readonly name: string; + readonly start: number; + readonly end: number; + readonly characters: string; + readonly minimum: number | null; +} + +/** Everything that the mock server reads from the spec folder. */ +export interface SpecFiles { + readonly fixtures: ReadonlyMap; + readonly keys: ReadonlyMap; + readonly requestsPerMinute: number; + readonly scenarios: ReadonlyMap; + readonly states: readonly string[]; + readonly segments: readonly SegmentRule[]; +} + +type JsonObject = Readonly>; + +function readJson(folder: string, name: string): unknown { + return JSON.parse(readFileSync(join(folder, name), 'utf8')); +} + +function objectIn(value: unknown, where: string): JsonObject { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`${where} must be a JSON object.`); + } + return value as JsonObject; +} + +function listIn(value: unknown, where: string): readonly unknown[] { + if (!Array.isArray(value)) { + throw new TypeError(`${where} must be a JSON array.`); + } + return value; +} + +function field( + record: JsonObject, + name: string, + where: string, + check: (value: unknown) => value is T, +): T { + const value = record[name]; + if (!check(value)) { + throw new TypeError(`${where}: the field ${name} has the wrong type.`); + } + return value; +} + +const isNumber = (value: unknown): value is number => typeof value === 'number'; +const isText = (value: unknown): value is string => typeof value === 'string'; +const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'; +const isTextList = (value: unknown): value is string[] => + Array.isArray(value) && value.every(isText); +const isTextListOrNull = (value: unknown): value is string[] | null => + value === null || isTextList(value); + +function textRecord(value: unknown, where: string): Readonly> { + const record = objectIn(value, where); + for (const name of Object.keys(record)) { + field(record, name, where, isText); + } + return record as Readonly>; +} + +function readFixtures(folder: string): Map { + const fixtures = new Map(); + const groups = readdirSync(folder, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), + ); + for (const group of groups) { + for (const file of readdirSync(join(folder, group.name)).filter((name) => + name.endsWith('.json'), + )) { + const name = `${group.name}/${file.slice(0, -'.json'.length)}`; + const record = objectIn(readJson(folder, `${name}.json`), `fixtures/${name}.json`); + const status = field(record, 'status', `fixtures/${name}.json`, isNumber); + fixtures.set(name, { status, body: record['body'] }); + } + } + return fixtures; +} + +function readKeys(folder: string): { keys: Map; requestsPerMinute: number } { + const where = 'fixtures/keys.json'; + const document = objectIn(readJson(folder, 'keys.json'), where); + const keys = new Map(); + for (const entry of listIn(document['keys'], where)) { + const record = objectIn(entry, where); + const key = field(record, 'key', where, isText); + keys.set(key, { + key, + lookupLevel: field(record, 'lookupLevel', where, isNumber), + lookupScope: field(record, 'lookupScope', where, isBoolean), + credits: field(record, 'credits', where, isBoolean), + rateLimited: field(record, 'rateLimited', where, isBoolean), + origins: field(record, 'origins', where, isTextListOrNull), + }); + } + return { keys, requestsPerMinute: field(document, 'requestsPerMinute', where, isNumber) }; +} + +function scenarioReply( + value: unknown, + fixtures: ReadonlyMap, + where: string, +): Reply { + const response = objectIn(value, where); + const delayMs = + response['delayMs'] === undefined ? 0 : field(response, 'delayMs', where, isNumber); + if (response['hang'] === true) { + return { kind: 'hang' }; + } + if (response['drop'] === true) { + return { kind: 'drop', delayMs }; + } + const headers = textRecord(response['headers'] ?? {}, where); + if (typeof response['fixture'] === 'string') { + const fixture = fixtures.get(response['fixture']); + if (fixture === undefined) { + throw new TypeError(`${where}: no fixture is named ${response['fixture']}.`); + } + return { ...jsonReply(fixture.status, fixture.body, headers), delayMs }; + } + const status = field(response, 'status', where, isNumber); + if (typeof response['text'] === 'string') { + const textHeaders = { 'Content-Type': 'text/plain; charset=utf-8', ...headers }; + return { kind: 'send', status, headers: textHeaders, body: response['text'], delayMs }; + } + return { ...jsonReply(status, response['body'], headers), delayMs }; +} + +function readScenarios( + folder: string, + fixtures: ReadonlyMap, +): Map { + const scenarios = new Map(); + for (const file of readdirSync(folder).filter((name) => name.endsWith('.json'))) { + const where = `contract/${file}`; + const scenario = objectIn(readJson(folder, file), where); + const id = field(scenario, 'id', where, isText); + const responses = listIn(scenario['responses'], where); + scenarios.set( + id, + responses.map((response) => scenarioReply(response, fixtures, where)), + ); + } + return scenarios; +} + +function readSegments(folder: string): SegmentRule[] { + const where = 'data/format.json'; + const document = objectIn(readJson(folder, 'format.json'), where); + let start = 0; + return listIn(document['segments'], where).map((entry) => { + const record = objectIn(entry, where); + const length = field(record, 'length', where, isNumber); + const minimum = + record['minimum'] === undefined ? null : field(record, 'minimum', where, isNumber); + const rule = { + name: field(record, 'name', where, isText), + start, + end: start + length, + characters: field(record, 'characters', where, isText), + minimum, + }; + start += length; + return rule; + }); +} + +function readStates(folder: string): string[] { + const where = 'data/states.json'; + const document = objectIn(readJson(folder, 'states.json'), where); + return listIn(document['states'], where).map((entry) => + field(objectIn(entry, where), 'code', where, isText), + ); +} + +/** + * Reads the fixtures, the keys, the scenarios and the postcode data from a spec folder. A file + * with the wrong shape stops the mock server, with a message that names the file. + * + * @param specDir - The spec folder, such as the `spec` submodule. + * @returns The files, ready for the mock server. + * @throws TypeError when a file has the wrong shape, and SyntaxError when it is not JSON. + * @internal + */ +export function readSpecFiles(specDir: string): SpecFiles { + const fixtures = readFixtures(join(specDir, 'fixtures')); + const { keys, requestsPerMinute } = readKeys(join(specDir, 'fixtures')); + return { + fixtures, + keys, + requestsPerMinute, + scenarios: readScenarios(join(specDir, 'contract'), fixtures), + states: readStates(join(specDir, 'data')), + segments: readSegments(join(specDir, 'data')), + }; +} + +/** + * Builds the reply of a fixture. Each change replaces one field of the body's `data` object. + * + * @param files - The spec files. + * @param name - The fixture's path under `spec/fixtures`, without `.json`. + * @param changes - The fields of `data` to replace, such as the echoed postcode. + * @returns The reply. + * @throws Error when the spec has no such fixture, or when changes meet a body with no `data`. + * @internal + */ +export function fixtureReply( + files: SpecFiles, + name: string, + changes: Readonly> = {}, +): Reply { + const fixture = files.fixtures.get(name); + if (fixture === undefined) { + throw new Error(`The spec has no fixture ${name}.`); + } + if (Object.keys(changes).length === 0) { + return jsonReply(fixture.status, fixture.body); + } + const body = objectIn(fixture.body, `fixtures/${name}.json`); + const data = objectIn(body['data'], `fixtures/${name}.json`); + return jsonReply(fixture.status, { ...body, data: { ...data, ...changes } }); +} diff --git a/packages/mock-server/test/mock-request.ts b/packages/mock-server/test/mock-request.ts new file mode 100644 index 0000000..b218c0e --- /dev/null +++ b/packages/mock-server/test/mock-request.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { fileURLToPath } from 'node:url'; +import type { MockRequest, Reply } from '../src/reply.ts'; +import { readSpecFiles, type SpecFiles } from '../src/spec-files.ts'; + +/** The spec submodule of the js repo. */ +export const SPEC_DIR: string = fileURLToPath(new URL('../../../spec/', import.meta.url)); + +/** The spec files, read once for all tests. */ +export const FILES: SpecFiles = readSpecFiles(SPEC_DIR); + +/** A response that a test can compare: its status, its headers and its parsed JSON body. */ +export interface SentResponse { + readonly status: number; + readonly headers: Readonly>; + readonly body: unknown; +} + +/** + * Builds a request as the server would read it. + * + * @param target - The path and the query, such as `/v1/lookup?code=FC01Z99ZZ01`. + * @param init - The method, the headers with names in lower case, and the body. + * @returns The request. + */ +export function mockRequest( + target: string, + init: { method?: string; headers?: Record; body?: string } = {}, +): MockRequest { + const url = new URL(target, 'http://mock.invalid'); + return { + method: init.method ?? 'GET', + path: url.pathname, + query: url.searchParams, + headers: init.headers ?? {}, + body: init.body ?? '', + }; +} + +/** + * Reads a reply that sends a response, and fails the test for any other reply. + * + * @param reply - The reply. + * @returns The status, the headers and the JSON body, or null for an empty body. + */ +export function sent(reply: Reply): SentResponse { + if (reply.kind !== 'send') { + throw new Error(`The reply is ${reply.kind}, not a response.`); + } + const body: unknown = reply.body === '' ? null : JSON.parse(reply.body); + return { status: reply.status, headers: reply.headers, body }; +} + +/** + * Gives the `error.code` of a response body. + * + * @param response - The response. + * @returns The code. + */ +export function errorCode(response: SentResponse): unknown { + const body = response.body as { error?: { code?: unknown } } | null; + return body?.error?.code; +} diff --git a/packages/mock-server/test/spec-files.test.ts b/packages/mock-server/test/spec-files.test.ts new file mode 100644 index 0000000..bff9f9b --- /dev/null +++ b/packages/mock-server/test/spec-files.test.ts @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { fixtureReply, readSpecFiles } from '../src/spec-files.ts'; +import { FILES, SPEC_DIR, sent } from './mock-request.ts'; + +const folders: string[] = []; + +// A copy of the spec folder in which one file has new text. +function specWith(file: string, text: string): string { + const folder = mkdtempSync(join(tmpdir(), 'gatepost-spec-')); + folders.push(folder); + for (const part of ['data', 'fixtures', 'contract']) { + cpSync(join(SPEC_DIR, part), join(folder, part), { recursive: true }); + } + writeFileSync(join(folder, file), text); + return folder; +} + +afterEach(() => { + for (const folder of folders.splice(0)) { + rmSync(folder, { recursive: true }); + } +}); + +describe('readSpecFiles', () => { + it('reads each key of keys.json with its behaviour', () => { + const keys = JSON.parse(readFileSync(join(SPEC_DIR, 'fixtures/keys.json'), 'utf8')) as { + keys: { key: string }[]; + }; + expect([...FILES.keys.keys()]).toEqual(keys.keys.map((entry) => entry.key)); + expect(FILES.keys.get('nipost_pk_test_mock')).toEqual({ + key: 'nipost_pk_test_mock', + lookupLevel: 3, + lookupScope: true, + credits: true, + rateLimited: false, + origins: ['http://localhost:3000'], + }); + expect(FILES.requestsPerMinute).toBe(600); + }); + + it('places each segment in the compact form', () => { + const places = FILES.segments.map((rule) => [rule.name, rule.start, rule.end, rule.minimum]); + expect(places).toEqual([ + ['state', 0, 2, null], + ['lga', 2, 4, 1], + ['district', 4, 7, null], + ['area', 7, 9, null], + ['unit', 9, 11, 1], + ]); + }); + + it('reads the 37 state codes', () => { + expect(FILES.states).toHaveLength(37); + expect(FILES.states).toContain('FC'); + }); + + it('reads every scenario in the contract folder, and does not use a fixed list', () => { + const ids = readdirSync(join(SPEC_DIR, 'contract')) + .filter((name) => name.endsWith('.json')) + .map((name) => name.slice(0, -'.json'.length)); + expect([...FILES.scenarios.keys()].sort()).toEqual(ids.sort()); + expect(ids.length).toBeGreaterThanOrEqual(69); + }); + + it('turns each response of a scenario into a reply', () => { + expect(FILES.scenarios.get('lookup-timeout')).toEqual([{ kind: 'hang' }]); + expect(FILES.scenarios.get('lookup-network-error')).toEqual([{ kind: 'drop', delayMs: 0 }]); + const [first] = FILES.scenarios.get('lookup-shared-request') ?? []; + expect(first).toMatchObject({ kind: 'send', status: 200, delayMs: 100 }); + const [page] = FILES.scenarios.get('lookup-malformed-body') ?? []; + expect(page).toMatchObject({ + kind: 'send', + status: 200, + headers: { 'Content-Type': 'text/html' }, + }); + }); + + it('adds the headers of a scenario response to a fixture', () => { + const [limited] = FILES.scenarios.get('lookup-retry-after') ?? []; + expect(limited).toMatchObject({ + status: 429, + headers: { 'Content-Type': 'application/json', 'Retry-After': '1' }, + }); + }); + + it('stops at a fixture with no status, and names the file', () => { + const folder = specWith('fixtures/lookup/invalid.json', '{"body": {}}'); + expect(() => readSpecFiles(folder)).toThrow( + 'fixtures/lookup/invalid.json: the field status has the wrong type.', + ); + }); + + it('stops at a scenario that names a fixture that does not exist', () => { + const scenario = { id: 'probe', responses: [{ fixture: 'lookup/valid-level-9' }] }; + const folder = specWith('contract/probe.json', JSON.stringify(scenario)); + expect(() => readSpecFiles(folder)).toThrow( + 'contract/probe.json: no fixture is named lookup/valid-level-9.', + ); + }); + + it('stops at a key file that is not JSON', () => { + expect(() => readSpecFiles(specWith('fixtures/keys.json', '{'))).toThrow(SyntaxError); + }); +}); + +describe('fixtureReply', () => { + it('replaces fields of data and keeps the rest of the body', () => { + const reply = fixtureReply(FILES, 'lookup/not-found', { postcode: 'FC01Z99ZZ09' }); + expect(sent(reply)).toEqual({ + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + data: { postcode: 'FC01Z99ZZ09', valid: false, status: 'not_found', verified: false }, + }, + }); + }); + + it('fails for a fixture that the spec does not have', () => { + expect(() => fixtureReply(FILES, 'lookup/valid-level-9')).toThrow( + 'The spec has no fixture lookup/valid-level-9.', + ); + }); +}); diff --git a/packages/mock-server/tsconfig.json b/packages/mock-server/tsconfig.json new file mode 100644 index 0000000..06cbf5e --- /dev/null +++ b/packages/mock-server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true + }, + "include": ["src", "test"] +} diff --git a/packages/mock-server/vitest.config.ts b/packages/mock-server/vitest.config.ts new file mode 100644 index 0000000..47661ad --- /dev/null +++ b/packages/mock-server/vitest.config.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + // main.ts runs only as a program. test/main.test.ts starts it in a child process. + exclude: ['src/main.ts'], + thresholds: { branches: 90, functions: 90, lines: 90, statements: 90 }, + }, + }, +}); diff --git a/scripts/eslint-mock-server.test.mjs b/scripts/eslint-mock-server.test.mjs new file mode 100644 index 0000000..a25dd9b --- /dev/null +++ b/scripts/eslint-mock-server.test.mjs @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The mock server is a dev tool that runs on Node, so the lint config lets its source use Node. +// The other rules stay, such as TS-12 on console calls. This file has its own process, because +// typescript-eslint keeps one project service for each process, and eslint-config.test.mjs +// sets that service up for the core package. +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { fileURLToPath, URL } from 'node:url'; +import { ESLint } from 'eslint'; + +const ROOT = fileURLToPath(new URL('../', import.meta.url)); +const SOURCE_FILE = 'packages/mock-server/src/probe.ts'; + +// The file does not exist. The project service lints it with the options of the package. +const eslint = new ESLint({ + cwd: ROOT, + overrideConfig: { + files: ['packages/mock-server/**/*.ts'], + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: [SOURCE_FILE], + defaultProject: 'packages/mock-server/tsconfig.json', + }, + }, + }, + }, +}); + +async function ruleIds(...rows) { + const [report] = await eslint.lintText(`${rows.join('\n')}\n`, { filePath: SOURCE_FILE }); + return report.messages.map((reported) => reported.ruleId); +} + +describe('the lint config in the mock server', () => { + it('allows a Node module and the process global', async () => { + const found = await ruleIds( + "import { createServer, type Server } from 'node:http';", + "import process from 'node:process';", + '/** The server, on the port that PORT names. */', + 'export const server: Server = createServer().listen(Number(process.env.PORT));', + ); + assert.deepEqual(found, []); + }); + + it('still rejects a console call, as TS-12 says', async () => { + const found = await ruleIds( + '/** Says hello. */', + 'export function hello(): void {', + " console.info('hi');", + '}', + ); + assert.ok(found.includes('no-console'), JSON.stringify(found)); + }); +}); From 85e6f1562d93cb029cfda2af6da536cf2097b4e4 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 01:34:24 +0100 Subject: [PATCH 03/14] feat(mock): answer the gateway endpoints from the fixtures Client tests and the docs playground need answers in the gateway's shapes, with its keys, levels, errors and rate-limit headers. Each answer comes from a fixture, and a test checks each body that the code builds or changes against the spec's OpenAPI file. The rules follow the calls of 2 Oct 2026 where the docs disagree: a level above the key's gets 403, and an empty autocomplete query gets no answer. Signed-off-by: MobileMage --- package.json | 4 +- packages/mock-server/src/assembly.ts | 65 ++++++++++++ packages/mock-server/src/gateway.ts | 73 +++++++++++++ packages/mock-server/src/lookup.ts | 62 +++++++++++ packages/mock-server/src/postcodes.ts | 60 +++++++++++ packages/mock-server/src/rate-limit.ts | 23 ++++ packages/mock-server/src/reply.ts | 23 ++++ packages/mock-server/src/search.ts | 102 ++++++++++++++++++ packages/mock-server/src/spec-files.ts | 19 ++++ packages/mock-server/test/assembly.test.ts | 48 +++++++++ packages/mock-server/test/gateway.test.ts | 103 ++++++++++++++++++ packages/mock-server/test/lookup.test.ts | 99 ++++++++++++++++++ packages/mock-server/test/openapi.test.ts | 99 ++++++++++++++++++ packages/mock-server/test/search.test.ts | 116 +++++++++++++++++++++ pnpm-lock.yaml | 19 +++- 15 files changed, 911 insertions(+), 4 deletions(-) create mode 100644 packages/mock-server/src/assembly.ts create mode 100644 packages/mock-server/src/gateway.ts create mode 100644 packages/mock-server/src/lookup.ts create mode 100644 packages/mock-server/src/postcodes.ts create mode 100644 packages/mock-server/src/rate-limit.ts create mode 100644 packages/mock-server/src/search.ts create mode 100644 packages/mock-server/test/assembly.test.ts create mode 100644 packages/mock-server/test/gateway.test.ts create mode 100644 packages/mock-server/test/lookup.test.ts create mode 100644 packages/mock-server/test/openapi.test.ts create mode 100644 packages/mock-server/test/search.test.ts diff --git a/package.json b/package.json index 60ca3a9..8492343 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@stryker-mutator/core": "^10.0.0", "@types/node": "^22.20.4", "@vitest/coverage-v8": "^5.0.3", + "ajv": "^8.20.0", "eslint": "^10.11.0", "eslint-plugin-jsdoc": "^65.0.1", "fast-check": "^4.10.2", @@ -51,6 +52,7 @@ "tsdown": "^0.23.0", "typescript": "6.0.3", "typescript-eslint": "^8.71.0", - "vitest": "^5.0.3" + "vitest": "^5.0.3", + "yaml": "^2.9.1" } } diff --git a/packages/mock-server/src/assembly.ts b/packages/mock-server/src/assembly.ts new file mode 100644 index 0000000..c995e6f --- /dev/null +++ b/packages/mock-server/src/assembly.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { compactCode, isWellFormed } from './postcodes.ts'; +import { jsonReply, type MockRequest, type Reply } from './reply.ts'; +import { fixtureReply, type SpecFiles } from './spec-files.ts'; + +function parts(compact: string, files: SpecFiles): string[] { + return files.segments.map((rule) => compact.slice(rule.start, rule.end)); +} + +function segmentTexts(body: string, files: SpecFiles): string[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + // A body that is not JSON is a bad request, as a missing field is. + return null; + } + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + const record = parsed as Readonly>; + const texts = files.segments.map((rule) => { + const text = record[rule.name]; + return typeof text === 'string' ? text.toUpperCase().padStart(rule.end - rule.start, '0') : ''; + }); + return texts.includes('') ? null : texts; +} + +/** + * Answers `POST /v1/assembly/assemble`. It changes letters to upper case, and pads each segment + * to its length with zeros, as the gateway does with `"lga": "1"`. + * + * @param request - The request. + * @param files - The spec files. + * @returns The reply. + * @internal + */ +export function assemble(request: MockRequest, files: SpecFiles): Reply { + const texts = segmentTexts(request.body, files); + if (texts === null || !isWellFormed(texts.join(''), files)) { + return fixtureReply(files, 'errors/invalid-request'); + } + return jsonReply(200, { + data: { compact: texts.join(''), display: texts.join(' '), postcode: texts.join('-') }, + }); +} + +/** + * Answers `GET /v1/assembly/disassemble`. + * + * @param request - The request. + * @param files - The spec files. + * @returns The reply. + * @internal + */ +export function disassemble(request: MockRequest, files: SpecFiles): Reply { + const compact = compactCode(request.query.get('code') ?? ''); + if (!isWellFormed(compact, files)) { + return fixtureReply(files, 'errors/invalid-request'); + } + const texts = parts(compact, files); + const data = Object.fromEntries(files.segments.map((rule, index) => [rule.name, texts[index]])); + return jsonReply(200, { data }); +} diff --git a/packages/mock-server/src/gateway.ts b/packages/mock-server/src/gateway.ts new file mode 100644 index 0000000..1507119 --- /dev/null +++ b/packages/mock-server/src/gateway.ts @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { assemble, disassemble } from './assembly.ts'; +import { lookup } from './lookup.ts'; +import { createRateLimiter } from './rate-limit.ts'; +import { emptyReply, withHeaders, type MockRequest, type Reply } from './reply.ts'; +import { autocomplete, nearby, reverse } from './search.ts'; +import { fixtureReply, type MockKey, type SpecFiles } from './spec-files.ts'; + +type Route = (request: MockRequest, files: SpecFiles, key: MockKey) => Reply; + +// SEC-4: the mock server serves the documented gateway paths and no other. +const ROUTES: ReadonlyMap = new Map([ + ['GET /v1/lookup', lookup], + ['GET /v1/search/reverse', reverse], + ['GET /v1/search/nearby', nearby], + ['GET /v1/search/autocomplete', autocomplete], + ['POST /v1/assembly/assemble', assemble], + ['GET /v1/assembly/disassemble', disassemble], +]); + +function keyedReply(request: MockRequest, files: SpecFiles, key: MockKey, left: number): Reply { + const route = ROUTES.get(`${request.method} ${request.path}`); + const origin = request.headers['origin']; + if (route === undefined) { + return fixtureReply(files, 'errors/unknown-path'); + } + if (key.origins !== null && origin !== undefined && !key.origins.includes(origin)) { + return fixtureReply(files, 'errors/origin-not-allowed'); + } + if (key.rateLimited) { + return withHeaders(fixtureReply(files, 'errors/rate-limited'), { 'Retry-After': '1' }); + } + return left < 0 ? fixtureReply(files, 'errors/rate-limited') : route(request, files, key); +} + +/** + * Builds the default behaviour of the mock server: the gateway's answers, from the fixtures and + * the keys. A request with no key gets 401 with no rate-limit headers, as the gateway gave. Every + * request with a known key counts against that key's limit for the clock minute. + * + * @param files - The spec files. + * @param now - The clock, in milliseconds since the epoch. + * @returns A function that answers one request. + * @internal + */ +export function createGateway( + files: SpecFiles, + now: () => number, +): (request: MockRequest) => Reply { + const count = createRateLimiter(files.requestsPerMinute, now); + return (request) => { + if (request.method === 'OPTIONS') { + return emptyReply(204); + } + if (request.method === 'GET' && request.path === '/healthz') { + return emptyReply(200); + } + const apiKey = request.headers['x-api-key']; + if (apiKey === undefined) { + return fixtureReply(files, 'errors/auth-required'); + } + const key = files.keys.get(apiKey); + if (key === undefined) { + return fixtureReply(files, 'errors/invalid-api-key'); + } + const left = count(apiKey); + return withHeaders(keyedReply(request, files, key, left), { + 'X-RateLimit-Limit': String(files.requestsPerMinute), + 'X-RateLimit-Remaining': String(Math.max(left, 0)), + }); + }; +} diff --git a/packages/mock-server/src/lookup.ts b/packages/mock-server/src/lookup.ts new file mode 100644 index 0000000..912df09 --- /dev/null +++ b/packages/mock-server/src/lookup.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { compactCode, isWellFormed } from './postcodes.ts'; +import type { MockRequest, Reply } from './reply.ts'; +import { fixtureData, fixtureReply, type MockKey, type SpecFiles } from './spec-files.ts'; + +const LEVEL = /^[1-5]$/; + +/** + * Gives the compact form of the one unit that the mock server knows: the postcode in the + * fixture `lookup/valid-level-1`. + * + * @param files - The spec files. + * @returns The compact postcode. + * @internal + */ +export function knownUnit(files: SpecFiles): string { + const postcode = fixtureData(files, 'lookup/valid-level-1', 'postcode'); + if (typeof postcode !== 'string') { + throw new TypeError('fixtures/lookup/valid-level-1.json needs a postcode.'); + } + return compactCode(postcode); +} + +// The checks run in the order that the gateway showed: a level above the key's level gets 403 +// before a missing credit could give 402. +function refusal(key: MockKey, level: number): string | null { + if (!key.lookupScope) { + return 'errors/scope-not-granted'; + } + if (level > key.lookupLevel) { + return `errors/level-not-granted-${String(key.lookupLevel)}`; + } + return level > 1 && !key.credits ? 'errors/insufficient-credits' : null; +} + +/** + * Answers `GET /v1/lookup`. The body echoes the caller's text in upper case, as the gateway does. + * + * @param request - The request. + * @param files - The spec files. + * @param key - The caller's key. + * @returns The reply. + * @internal + */ +export function lookup(request: MockRequest, files: SpecFiles, key: MockKey): Reply { + const code = request.query.get('code') ?? ''; + const level = request.query.get('level') ?? '1'; + if (compactCode(code) === '' || !LEVEL.test(level)) { + return fixtureReply(files, 'errors/invalid-request'); + } + const refused = refusal(key, Number(level)); + if (refused !== null) { + return fixtureReply(files, refused); + } + const compact = compactCode(code); + let name = isWellFormed(compact, files) ? 'lookup/not-found' : 'lookup/invalid'; + if (compact === knownUnit(files)) { + name = `lookup/valid-level-${level}`; + } + return fixtureReply(files, name, { postcode: code.toUpperCase() }); +} diff --git a/packages/mock-server/src/postcodes.ts b/packages/mock-server/src/postcodes.ts new file mode 100644 index 0000000..08f1180 --- /dev/null +++ b/packages/mock-server/src/postcodes.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import type { SegmentRule, SpecFiles } from './spec-files.ts'; + +// The mock server reads postcodes as the gateway does, not as the core does. The gateway +// ignores spaces, hyphens and letter case, and it does no NFKC. +const SEPARATORS = /[\s-]/g; +const CHARACTERS: ReadonlyMap = new Map([ + ['letters', /^[A-Z]+$/], + ['digits', /^[0-9]+$/], + ['letters-or-digits', /^[A-Z0-9]+$/], +]); + +/** + * Removes spaces and hyphens, and changes letters to upper case. + * + * @param text - The text of a request. + * @returns The compact form of the text. + * @internal + */ +export function compactCode(text: string): string { + return text.toUpperCase().replace(SEPARATORS, ''); +} + +function segmentPasses(rule: SegmentRule, text: string): boolean { + const pattern = CHARACTERS.get(rule.characters); + return pattern?.test(text) === true && (rule.minimum === null || Number(text) >= rule.minimum); +} + +/** + * Tells whether a compact code has the form of a full postcode, with a known state. + * + * @param compact - The code in compact form. + * @param files - The segment rules and the state codes. + * @returns True for a full postcode in the right form. + * @internal + */ +export function isWellFormed(compact: string, files: SpecFiles): boolean { + const full = files.segments.at(-1)?.end; + return ( + compact.length === full && + files.states.includes(compact.slice(0, 2)) && + files.segments.every((rule) => segmentPasses(rule, compact.slice(rule.start, rule.end))) + ); +} + +/** + * Finds the segment that holds the last character of a code of this length. + * + * @param length - The length of a compact code. + * @param segments - The segment rules. + * @returns The segment, or undefined for a length of 0 or a code that is too long. + * @internal + */ +export function activeSegment( + length: number, + segments: readonly SegmentRule[], +): SegmentRule | undefined { + return segments.find((rule) => length > rule.start && length <= rule.end); +} diff --git a/packages/mock-server/src/rate-limit.ts b/packages/mock-server/src/rate-limit.ts new file mode 100644 index 0000000..f426a6d --- /dev/null +++ b/packages/mock-server/src/rate-limit.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +const MINUTE_MS = 60_000; + +/** + * Counts the requests of each key in each clock minute, as the gateway does. + * + * @param limit - The requests that one key can make in one minute. + * @param now - The clock, in milliseconds since the epoch. + * @returns A function that counts one request of a key, and returns the requests that the key + * has left in this minute. The count goes below 0 when the key passes the limit. + * @internal + */ +export function createRateLimiter(limit: number, now: () => number): (key: string) => number { + const minutes = new Map(); + return (key) => { + const minute = Math.floor(now() / MINUTE_MS); + const current = minutes.get(key); + const count = current?.minute === minute ? current.count + 1 : 1; + minutes.set(key, { minute, count }); + return limit - count; + }; +} diff --git a/packages/mock-server/src/reply.ts b/packages/mock-server/src/reply.ts index 666bef1..299cf53 100644 --- a/packages/mock-server/src/reply.ts +++ b/packages/mock-server/src/reply.ts @@ -53,3 +53,26 @@ export function jsonReply( delayMs: 0, }; } + +/** + * Builds a reply with no body, such as the answer to a preflight request. + * + * @param status - The HTTP status. + * @returns The reply, with no delay. + * @internal + */ +export function emptyReply(status: number): SendReply { + return { kind: 'send', status, headers: {}, body: '', delayMs: 0 }; +} + +/** + * Adds headers to a reply that sends a response. A reply of another kind stays as it is. + * + * @param reply - The reply. + * @param headers - The headers to add. They replace a header of the same name. + * @returns The reply with the headers. + * @internal + */ +export function withHeaders(reply: Reply, headers: Readonly>): Reply { + return reply.kind === 'send' ? { ...reply, headers: { ...reply.headers, ...headers } } : reply; +} diff --git a/packages/mock-server/src/search.ts b/packages/mock-server/src/search.ts new file mode 100644 index 0000000..03d53ef --- /dev/null +++ b/packages/mock-server/src/search.ts @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { knownUnit } from './lookup.ts'; +import { activeSegment, compactCode } from './postcodes.ts'; +import { jsonReply, type MockRequest, type Reply } from './reply.ts'; +import { fixtureData, fixtureReply, type SpecFiles } from './spec-files.ts'; + +/** NIPOST's docs give the default radius of reverse, and the ceiling that the gateway applies. */ +export const REVERSE_RADIUS_M = { default: 25, maximum: 250 } as const; +const PLACES = ['reverse/unit', 'reverse/area'] as const; +const CODE_CHARACTERS = /^[A-Z0-9]+$/; + +function numberIn(query: URLSearchParams, name: string): number | null { + const raw = query.get(name)?.trim() ?? ''; + const parsed = Number(raw); + return raw === '' || !Number.isFinite(parsed) ? null : parsed; +} + +function radiusIn(query: URLSearchParams, name: string, fallback: number): number | null { + if (query.get(name) === null) { + return fallback; + } + const radius = numberIn(query, name); + return radius === null || radius < 0 ? null : radius; +} + +function samePlace(files: SpecFiles, name: string, lng: number, lat: number): boolean { + const coordinate = fixtureData(files, name, 'coordinate'); + return Array.isArray(coordinate) && coordinate[0] === lng && coordinate[1] === lat; +} + +/** + * Answers `GET /v1/search/reverse`. A point that a fixture names gets that fixture. Any other + * point gets the fixture for no postcode in range. The body echoes the coordinate as + * [lng, lat] and gives the radius that the mock server applied. + * + * @param request - The request. + * @param files - The spec files. + * @returns The reply. + * @internal + */ +export function reverse(request: MockRequest, files: SpecFiles): Reply { + const lat = numberIn(request.query, 'lat'); + const lng = numberIn(request.query, 'lng'); + const radius = radiusIn(request.query, 'max_distance_m', REVERSE_RADIUS_M.default); + if (lat === null || lng === null || radius === null) { + return fixtureReply(files, 'errors/invalid-request'); + } + const name = PLACES.find((place) => samePlace(files, place, lng, lat)) ?? 'reverse/not-found'; + const radiusM = Math.min(radius, REVERSE_RADIUS_M.maximum); + return fixtureReply(files, name, { coordinate: [lng, lat], radius_m: radiusM }); +} + +/** + * Answers `GET /v1/search/nearby` with the one body that the gateway has shown: an empty list. + * + * @param request - The request. + * @param files - The spec files. + * @returns The reply. + * @internal + */ +export function nearby(request: MockRequest, files: SpecFiles): Reply { + const lat = numberIn(request.query, 'lat'); + const lng = numberIn(request.query, 'lng'); + if (lat === null || lng === null || radiusIn(request.query, 'radius', 0) === null) { + return fixtureReply(files, 'errors/invalid-request'); + } + return fixtureReply(files, 'nearby/empty'); +} + +/** + * Answers `GET /v1/search/autocomplete`. The state segment completes from the state list. + * The other segments complete from the one unit that the mock server knows. An empty `q` gets + * no answer, as the gateway gave none. + * + * @param request - The request. + * @param files - The spec files. + * @returns The reply. + * @internal + */ +export function autocomplete(request: MockRequest, files: SpecFiles): Reply { + const typed = compactCode(request.query.get('q') ?? ''); + if (typed === '') { + return { kind: 'hang' }; + } + const segment = activeSegment(typed.length, files.segments); + if (segment === undefined || !CODE_CHARACTERS.test(typed)) { + return fixtureReply(files, 'errors/invalid-request'); + } + const before = typed.slice(0, segment.start); + const unit = knownUnit(files); + const values = + segment.start === 0 + ? files.states + : [unit] + .filter((code) => code.startsWith(before)) + .map((code) => code.slice(segment.start, segment.end)); + const started = typed.slice(segment.start); + const codes = [...new Set(values)].filter((value) => value.startsWith(started)).sort(); + const suggestions = codes.map((code) => ({ code })); + return jsonReply(200, { data: { segment: segment.name, suggestions } }); +} diff --git a/packages/mock-server/src/spec-files.ts b/packages/mock-server/src/spec-files.ts index d423888..c7dd5a8 100644 --- a/packages/mock-server/src/spec-files.ts +++ b/packages/mock-server/src/spec-files.ts @@ -250,3 +250,22 @@ export function fixtureReply( const data = objectIn(body['data'], `fixtures/${name}.json`); return jsonReply(fixture.status, { ...body, data: { ...data, ...changes } }); } + +/** + * Reads one field of the `data` object of a fixture's body. + * + * @param files - The spec files. + * @param name - The fixture's path under `spec/fixtures`, without `.json`. + * @param fieldName - The field of `data`. + * @returns The value of the field. + * @throws Error when the spec has no such fixture, and TypeError when the body has no `data`. + * @internal + */ +export function fixtureData(files: SpecFiles, name: string, fieldName: string): unknown { + const fixture = files.fixtures.get(name); + if (fixture === undefined) { + throw new Error(`The spec has no fixture ${name}.`); + } + const body = objectIn(fixture.body, `fixtures/${name}.json`); + return objectIn(body['data'], `fixtures/${name}.json`)[fieldName]; +} diff --git a/packages/mock-server/test/assembly.test.ts b/packages/mock-server/test/assembly.test.ts new file mode 100644 index 0000000..32a2bd6 --- /dev/null +++ b/packages/mock-server/test/assembly.test.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { createGateway } from '../src/gateway.ts'; +import { errorCode, FILES, mockRequest, sent, type SentResponse } from './mock-request.ts'; + +const KEY = { 'x-api-key': 'nipost_test_mock_l1' }; + +function assemble(body: string): SentResponse { + const request = mockRequest('/v1/assembly/assemble', { method: 'POST', headers: KEY, body }); + return sent(createGateway(FILES, () => 0)(request)); +} + +function disassemble(code: string): SentResponse { + const request = mockRequest(`/v1/assembly/disassemble?code=${code}`, { headers: KEY }); + return sent(createGateway(FILES, () => 0)(request)); +} + +describe('assemble', () => { + it('changes letters to upper case and pads numbers, as the gateway did', () => { + const segments = { state: 'fc', lga: '1', district: 'z99', area: 'zz', unit: '1' }; + expect(assemble(JSON.stringify(segments)).body).toEqual({ + data: { compact: 'FC01Z99ZZ01', display: 'FC 01 Z99 ZZ 01', postcode: 'FC-01-Z99-ZZ-01' }, + }); + }); + + it.each([ + ['a body that is not JSON', '{state'], + ['a body that is a list', '[]'], + ['a missing unit', '{"state":"FC","lga":"01","district":"Z99","area":"ZZ"}'], + ['a unit of 0', '{"state":"FC","lga":"01","district":"Z99","area":"ZZ","unit":"0"}'], + ])('answers %s with 400', (_name, body) => { + const response = assemble(body); + expect([response.status, errorCode(response)]).toEqual([400, 'invalid_request']); + }); +}); + +describe('disassemble', () => { + it('splits a postcode into its five segments', () => { + expect(disassemble('fc01z99zz01').body).toEqual({ + data: { state: 'FC', lga: '01', district: 'Z99', area: 'ZZ', unit: '01' }, + }); + }); + + it('answers text that is not a postcode with 400', () => { + expect(disassemble('HELLO').status).toBe(400); + }); +}); diff --git a/packages/mock-server/test/gateway.test.ts b/packages/mock-server/test/gateway.test.ts new file mode 100644 index 0000000..3426fdf --- /dev/null +++ b/packages/mock-server/test/gateway.test.ts @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { createGateway } from '../src/gateway.ts'; +import { errorCode, FILES, mockRequest, sent } from './mock-request.ts'; + +const LOOKUP = '/v1/lookup?code=FC-01-Z99-ZZ-01'; +const L3 = { 'x-api-key': 'nipost_test_mock_l3' }; + +function remaining(response: { headers: Readonly> }): string | undefined { + return response.headers['X-RateLimit-Remaining']; +} + +describe('the gateway without a scenario', () => { + it('answers a request with no key with auth_required and no rate-limit headers', () => { + const response = sent(createGateway(FILES, () => 0)(mockRequest(LOOKUP))); + expect(response.status).toBe(401); + expect(response.body).toEqual({ + error: { + code: 'auth_required', + message: 'send an API key in the X-API-Key header', + }, + }); + expect(response.headers).not.toHaveProperty('X-RateLimit-Limit'); + }); + + it('answers a key that it does not know with invalid_api_key', () => { + const request = mockRequest(LOOKUP, { headers: { 'x-api-key': 'nipost_test_other' } }); + const response = sent(createGateway(FILES, () => 0)(request)); + expect([response.status, errorCode(response)]).toEqual([401, 'invalid_api_key']); + expect(response.headers).not.toHaveProperty('X-RateLimit-Remaining'); + }); + + it('counts the requests of each key in each clock minute', () => { + let clock = 59_000; + const gateway = createGateway(FILES, () => clock); + const l1 = { 'x-api-key': 'nipost_test_mock_l1' }; + expect(remaining(sent(gateway(mockRequest(LOOKUP, { headers: L3 }))))).toBe('599'); + expect(remaining(sent(gateway(mockRequest(LOOKUP, { headers: L3 }))))).toBe('598'); + expect(remaining(sent(gateway(mockRequest(LOOKUP, { headers: l1 }))))).toBe('599'); + clock = 60_000; + const response = sent(gateway(mockRequest(LOOKUP, { headers: L3 }))); + expect(response.headers['X-RateLimit-Limit']).toBe('600'); + expect(remaining(response)).toBe('599'); + }); + + it('answers the request after the limit with 429 and no Retry-After', () => { + const gateway = createGateway(FILES, () => 0); + for (let count = 0; count < 600; count += 1) { + gateway(mockRequest(LOOKUP, { headers: L3 })); + } + const response = sent(gateway(mockRequest(LOOKUP, { headers: L3 }))); + expect([response.status, errorCode(response), remaining(response)]).toEqual([ + 429, + 'rate_limited', + '0', + ]); + expect(response.headers).not.toHaveProperty('Retry-After'); + }); + + it('answers the rate-limited key with 429 and Retry-After of 1 second', () => { + const headers = { 'x-api-key': 'nipost_test_mock_rate_limited' }; + const response = sent(createGateway(FILES, () => 0)(mockRequest(LOOKUP, { headers }))); + expect([response.status, errorCode(response)]).toEqual([429, 'rate_limited']); + expect(response.headers['Retry-After']).toBe('1'); + }); + + it('refuses a publishable key from an origin that is not on its list, and counts it', () => { + const headers = { 'x-api-key': 'nipost_pk_test_mock', origin: 'http://localhost:3001' }; + const response = sent(createGateway(FILES, () => 0)(mockRequest(LOOKUP, { headers }))); + expect([response.status, errorCode(response), remaining(response)]).toEqual([ + 403, + 'origin_not_allowed', + '599', + ]); + }); + + it('accepts a publishable key from its origin, and from an app with no Origin header', () => { + const gateway = createGateway(FILES, () => 0); + const allowed = { 'x-api-key': 'nipost_pk_test_mock', origin: 'http://localhost:3000' }; + expect(sent(gateway(mockRequest(LOOKUP, { headers: allowed }))).status).toBe(200); + const app = { 'x-api-key': 'nipost_pk_test_mock' }; + expect(sent(gateway(mockRequest(LOOKUP, { headers: app }))).status).toBe(200); + }); + + it('answers a preflight request and the health check with no key', () => { + const gateway = createGateway(FILES, () => 0); + expect(sent(gateway(mockRequest(LOOKUP, { method: 'OPTIONS' })))).toEqual({ + status: 204, + headers: {}, + body: null, + }); + expect(sent(gateway(mockRequest('/healthz'))).status).toBe(200); + }); + + it('answers a path that the gateway does not document with 404', () => { + const gateway = createGateway(FILES, () => 0); + const widget = sent(gateway(mockRequest('/v1/widget/lookup', { headers: L3 }))); + expect([widget.status, errorCode(widget)]).toEqual([404, 'not_found']); + const post = sent(gateway(mockRequest(LOOKUP, { method: 'POST', headers: L3 }))); + expect(post.status).toBe(404); + }); +}); diff --git a/packages/mock-server/test/lookup.test.ts b/packages/mock-server/test/lookup.test.ts new file mode 100644 index 0000000..ee59c8d --- /dev/null +++ b/packages/mock-server/test/lookup.test.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { createGateway } from '../src/gateway.ts'; +import { errorCode, FILES, mockRequest, sent, type SentResponse } from './mock-request.ts'; + +function lookUp(query: string, key = 'nipost_test_mock_l3'): SentResponse { + const request = mockRequest(`/v1/lookup?${query}`, { headers: { 'x-api-key': key } }); + return sent(createGateway(FILES, () => 0)(request)); +} + +function message(response: SentResponse): unknown { + return (response.body as { error: { message: unknown } }).error.message; +} + +describe('lookup', () => { + it('finds the synthetic unit at level 1 when no level is given', () => { + expect(lookUp('code=FC-01-Z99-ZZ-01').body).toEqual({ + data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid', verified: false }, + }); + }); + + it('echoes the caller text in upper case, as the gateway does', () => { + const response = lookUp('code=fc+01+z99+zz+01&level=1'); + expect(response.body).toMatchObject({ data: { postcode: 'FC 01 Z99 ZZ 01', valid: true } }); + }); + + it('adds the documented address fields at level 2', () => { + expect(lookUp('code=FC01Z99ZZ01&level=2').body).toEqual({ + data: { + postcode: 'FC01Z99ZZ01', + valid: true, + administrative_address: { + state_name: 'FEDERAL CAPITAL TERRITORY', + lga_name: 'SYNTHETIC LGA', + locality_name: 'SYNTHETIC LOCALITY', + zone: 'NORTH CENTRAL', + }, + recent_house_address: { recent: '1 SYNTHETIC STREET, SYNTHETIC LOCALITY' }, + }, + }); + }); + + it('adds the building use status at level 3', () => { + const response = lookUp('code=FC01Z99ZZ01&level=3'); + expect(response.body).toMatchObject({ data: { building_use_status: 'residential' } }); + }); + + it('gives valid false with not_found for a well-formed postcode that it does not know', () => { + expect(lookUp('code=FC-01-Z99-ZZ-02').body).toEqual({ + data: { postcode: 'FC-01-Z99-ZZ-02', valid: false, status: 'not_found', verified: false }, + }); + }); + + it.each([ + ['text that is not a postcode', 'HELLO', 'HELLO'], + ['a unit of 00', 'fc-01-z99-zz-00', 'FC-01-Z99-ZZ-00'], + ['a state that is not on the list', 'XX01Z99ZZ01', 'XX01Z99ZZ01'], + ])('gives valid false with invalid for %s', (_name, code, echo) => { + expect(lookUp(`code=${code}`).body).toEqual({ + data: { postcode: echo, valid: false, status: 'invalid', verified: false }, + }); + }); + + it.each([ + ['nipost_test_mock_l1', 2, 'this key cannot read above lookup level 1'], + ['nipost_test_mock_l2', 3, 'this key cannot read above lookup level 2'], + ['nipost_test_mock_l3', 4, 'this key cannot read above lookup level 3'], + ])('refuses %s at level %i with level_not_granted', (key, level, text) => { + const response = lookUp(`code=FC01Z99ZZ01&level=${String(level)}`, key); + expect([response.status, errorCode(response), message(response)]).toEqual([ + 403, + 'level_not_granted', + text, + ]); + }); + + it('refuses a level above 1 with no credits, and serves level 1', () => { + const refused = lookUp('code=FC01Z99ZZ01&level=2', 'nipost_test_mock_no_credits'); + expect([refused.status, errorCode(refused)]).toEqual([402, 'insufficient_credits']); + expect(lookUp('code=FC01Z99ZZ01&level=1', 'nipost_test_mock_no_credits').status).toBe(200); + }); + + it('refuses every level to a key with no lookup scope', () => { + const response = lookUp('code=FC01Z99ZZ01&level=1', 'nipost_test_mock_no_scope'); + expect([response.status, errorCode(response)]).toEqual([403, 'scope_not_granted']); + }); + + it.each([ + ['no code', 'level=1'], + ['a code of separators only', 'code=+-+'], + ['level 0', 'code=FC01Z99ZZ01&level=0'], + ['level 6', 'code=FC01Z99ZZ01&level=6'], + ['a level that is not a number', 'code=FC01Z99ZZ01&level=two'], + ])('answers %s with 400', (_name, query) => { + const response = lookUp(query); + expect([response.status, errorCode(response)]).toEqual([400, 'invalid_request']); + }); +}); diff --git a/packages/mock-server/test/openapi.test.ts b/packages/mock-server/test/openapi.test.ts new file mode 100644 index 0000000..3086749 --- /dev/null +++ b/packages/mock-server/test/openapi.test.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Every body that the mock server builds must match the completed OpenAPI file in the spec. The +// spec checks its fixtures. This test checks the bodies that the code builds or changes. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { parse } from 'yaml'; +import { describe, expect, it } from 'vitest'; +import { createGateway } from '../src/gateway.ts'; +import { REVERSE_RADIUS_M } from '../src/search.ts'; +import { FILES, mockRequest, SPEC_DIR, sent } from './mock-request.ts'; + +interface ResponseObject { + readonly $ref?: string; + readonly content?: unknown; +} + +interface Operation { + readonly parameters?: readonly { + readonly name: string; + readonly schema: Record; + }[]; + readonly responses: Readonly>; +} + +const openapi = parse(readFileSync(join(SPEC_DIR, 'openapi/gateway.completed.yaml'), 'utf8')) as { + paths: Record>; + components: { responses: Record }; + 'x-gatepost-unknown-path': { responses: Record }; +}; +const ajv = new Ajv2020({ strict: false }); +ajv.addSchema(openapi, 'openapi'); + +// The response of the operation and the status, or the file's answer to a path that it lacks. +function bodySchema(path: string, method: string, status: number): string { + const responses = + openapi.paths[path]?.[method]?.responses ?? openapi['x-gatepost-unknown-path'].responses; + const response = responses[String(status)]; + const shared = response?.$ref?.split('/').at(-1); + const content = + shared === undefined ? response?.content : openapi.components.responses[shared]?.content; + const media = (content as { 'application/json': { schema: { $ref: string } } })[ + 'application/json' + ]; + return `openapi${media.schema.$ref}`; +} + +interface Probe { + readonly target: string; + readonly method?: string; + readonly key?: string; + readonly body?: string; +} + +const SEGMENTS = '{"state":"fc","lga":"1","district":"z99","area":"zz","unit":"1"}'; +const PROBES: readonly Probe[] = [ + { target: '/v1/lookup?code=fc01z99zz01&level=3' }, + { target: '/v1/lookup?code=FC01Z99ZZ01&level=2' }, + { target: '/v1/lookup?code=HELLO' }, + { target: '/v1/lookup?code=FC01Z99ZZ02' }, + { target: '/v1/lookup?code=FC01Z99ZZ01&level=5' }, + { target: '/v1/lookup?level=1' }, + { target: '/v1/lookup?code=FC01Z99ZZ01', key: 'nipost_test_mock_no_scope' }, + { target: '/v1/lookup?code=FC01Z99ZZ01&level=2', key: 'nipost_test_mock_no_credits' }, + { target: '/v1/lookup?code=FC01Z99ZZ01', key: 'nipost_test_mock_rate_limited' }, + { target: '/v1/lookup?code=FC01Z99ZZ01', key: 'nipost_test_unknown' }, + { target: '/v1/search/reverse?lat=9&lng=7&max_distance_m=900' }, + { target: '/v1/search/reverse?lat=9.001&lng=7.001' }, + { target: '/v1/search/reverse?lat=0.5&lng=0.25' }, + { target: '/v1/search/nearby?lat=9&lng=7' }, + { target: '/v1/search/autocomplete?q=E' }, + { target: '/v1/search/autocomplete?q=FC01Z99ZZ0' }, + { target: '/v1/search/autocomplete?q=EK01' }, + { target: '/v1/assembly/assemble', method: 'POST', body: SEGMENTS }, + { target: '/v1/assembly/disassemble?code=FC01Z99ZZ01' }, + { target: '/v1/assembly/disassemble?code=HELLO' }, + { target: '/v1/widget/lookup' }, +]; + +describe('the bodies of the mock server', () => { + it.each(PROBES)('match the OpenAPI file for $target', (probe) => { + const method = probe.method ?? 'GET'; + const headers = { 'x-api-key': probe.key ?? 'nipost_test_mock_l3' }; + const request = mockRequest(probe.target, { method, headers, body: probe.body ?? '' }); + const response = sent(createGateway(FILES, () => 0)(request)); + const validate = ajv.getSchema(bodySchema(request.path, method.toLowerCase(), response.status)); + expect(validate?.(response.body), JSON.stringify(validate?.errors)).toBe(true); + }); +}); + +describe('the reverse radius', () => { + it('has the default and the ceiling of the OpenAPI file', () => { + const parameter = openapi.paths['/v1/search/reverse']?.['get']?.parameters?.find( + ({ name }) => name === 'max_distance_m', + ); + expect(parameter?.schema).toMatchObject(REVERSE_RADIUS_M); + }); +}); diff --git a/packages/mock-server/test/search.test.ts b/packages/mock-server/test/search.test.ts new file mode 100644 index 0000000..d4771b0 --- /dev/null +++ b/packages/mock-server/test/search.test.ts @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { createGateway } from '../src/gateway.ts'; +import type { Reply } from '../src/reply.ts'; +import { errorCode, FILES, mockRequest, sent, type SentResponse } from './mock-request.ts'; + +function ask(target: string): Reply { + const request = mockRequest(target, { headers: { 'x-api-key': 'nipost_test_mock_l1' } }); + return createGateway(FILES, () => 0)(request); +} + +function data(response: SentResponse): unknown { + return (response.body as { data: unknown }).data; +} + +describe('reverse', () => { + it('finds the synthetic unit at its point', () => { + expect(data(sent(ask('/v1/search/reverse?lat=9&lng=7')))).toEqual({ + found: true, + coordinate: [7, 9], + unit: { + postcode: 'FC-01-Z99-ZZ-01', + display: 'FC 01 Z99 ZZ 01', + distance_m: 4.2, + confidence: 'high', + }, + area: 'FC-01-Z99-ZZ', + district: 'FC-01-Z99', + state: 'FC', + depth: 'unit', + radius_m: 25, + }); + }); + + it('matches a point that the client writes as 9.000 and 7.0', () => { + const found = data(sent(ask('/v1/search/reverse?lat=9.000&lng=7.0'))); + expect(found).toMatchObject({ found: true, coordinate: [7, 9], depth: 'unit' }); + }); + + it('finds the area with no unit at its point', () => { + const found = data(sent(ask('/v1/search/reverse?lat=9.001&lng=7.001&max_distance_m=40'))); + expect(found).toEqual({ + found: true, + coordinate: [7.001, 9.001], + area: 'FC-01-Z99-ZZ', + district: 'FC-01-Z99', + state: 'FC', + depth: 'area', + radius_m: 40, + }); + }); + + it('finds nothing elsewhere, and echoes the point as [lng, lat]', () => { + expect(data(sent(ask('/v1/search/reverse?lat=4&lng=3')))).toEqual({ + found: false, + coordinate: [3, 4], + message: 'no postcode found near this point', + radius_m: 25, + }); + }); + + it('lowers a radius above 250 m to 250 m', () => { + const found = data(sent(ask('/v1/search/reverse?lat=4&lng=3&max_distance_m=900'))); + expect(found).toMatchObject({ radius_m: 250 }); + }); + + it.each([ + ['no latitude', 'lng=7'], + ['a latitude that is not a number', 'lat=north&lng=7'], + ['an empty longitude', 'lat=9&lng='], + ['a negative radius', 'lat=9&lng=7&max_distance_m=-1'], + ])('answers %s with 400', (_name, query) => { + const response = sent(ask(`/v1/search/reverse?${query}`)); + expect([response.status, errorCode(response)]).toEqual([400, 'invalid_request']); + }); +}); + +describe('nearby', () => { + it('gives the empty list, the only body that the gateway has shown', () => { + expect(sent(ask('/v1/search/nearby?lat=9&lng=7&radius=200')).body).toEqual({ data: [] }); + }); + + it('answers a point with no longitude with 400', () => { + expect(sent(ask('/v1/search/nearby?lat=9')).status).toBe(400); + }); +}); + +describe('autocomplete', () => { + it.each([ + ['E', 'state', ['EB', 'ED', 'EK', 'EN']], + ['FC', 'state', ['FC']], + ['FC0', 'lga', ['01']], + ['FC01Z', 'district', ['Z99']], + ['fc 01 z99 z', 'area', ['ZZ']], + ['FC-01-Z99-ZZ-0', 'unit', ['01']], + ['EK01', 'lga', []], + ])('completes %s in the %s segment', (q, segment, codes) => { + const response = sent(ask(`/v1/search/autocomplete?q=${encodeURIComponent(q)}`)); + expect(data(response)).toEqual({ segment, suggestions: codes.map((code) => ({ code })) }); + }); + + it.each([ + ['an empty q', ''], + ['a q of separators only', '%20-%20'], + ])('sends no answer to %s, as the gateway did', (_name, q) => { + expect(ask(`/v1/search/autocomplete?q=${q}`)).toEqual({ kind: 'hang' }); + }); + + it.each([ + ['a q of 12 characters', 'FC01Z99ZZ011'], + ['a q with a character that no postcode has', 'F%21'], + ])('answers %s with 400', (_name, q) => { + expect(sent(ask(`/v1/search/autocomplete?q=${q}`)).status).toBe(400); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccd4f37..9612e03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ importers: '@vitest/coverage-v8': specifier: ^5.0.3 version: 5.0.3(vitest@5.0.3) + ajv: + specifier: ^8.20.0 + version: 8.20.0 eslint: specifier: ^10.11.0 version: 10.11.0(jiti@2.6.1)(supports-color@8.1.1) @@ -232,9 +235,14 @@ importers: vitest: specifier: ^5.0.3 version: 5.0.3(@types/node@22.20.4)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1)) + yaml: + specifier: ^2.9.1 + version: 2.9.1 packages/core: {} + packages/mock-server: {} + packages: '@andrewbranch/untar.js@1.0.4': @@ -1362,6 +1370,11 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -3696,7 +3709,7 @@ snapshots: dependencies: ajv: 8.20.0 ajv-draft-04: 1.0.0(ajv@8.20.0) - ajv-formats: 3.0.1 + ajv-formats: 3.0.1(ajv@8.20.0) fs-extra: 11.3.6 import-lazy: 4.0.0 jju: 1.4.0 @@ -4048,8 +4061,8 @@ snapshots: optionalDependencies: ajv: 8.20.0 - ajv-formats@3.0.1: - dependencies: + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: ajv: 8.20.0 ajv@6.15.0: From 1a9dfc0705ab5ba39dee8538cd321e79092355c7 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 01:38:03 +0100 Subject: [PATCH 04/14] fix(mock): stop at start with the file name for every wrong spec file A spec file that is not JSON now fails with its path and the parser message. A response must have exactly one kind field, and a fixture must have a body, so the server cannot send the text "undefined". A repeated scenario id or key fails with both places, and a scenario id must equal its file name. Status, delay, level and rate values must be whole numbers in range. Messages name the response or key entry. Folders are read in sorted order, so the map order does not depend on the file system. Signed-off-by: MobileMage --- packages/mock-server/src/spec-files.ts | 110 ++++++++++---- packages/mock-server/test/spec-files.test.ts | 142 ++++++++++++++++++- scripts/eslint-mock-server.test.mjs | 8 ++ 3 files changed, 229 insertions(+), 31 deletions(-) diff --git a/packages/mock-server/src/spec-files.ts b/packages/mock-server/src/spec-files.ts index c7dd5a8..cd1fab0 100644 --- a/packages/mock-server/src/spec-files.ts +++ b/packages/mock-server/src/spec-files.ts @@ -39,10 +39,25 @@ export interface SpecFiles { readonly segments: readonly SegmentRule[]; } +// A response has exactly one of these fields. +const RESPONSE_KINDS = ['body', 'text', 'fixture', 'hang', 'drop'] as const; + type JsonObject = Readonly>; -function readJson(folder: string, name: string): unknown { - return JSON.parse(readFileSync(join(folder, name), 'utf8')); +// `where` is the file's path under the spec folder, such as `contract/probe.json`. +function readJson(folder: string, name: string, where: string): unknown { + try { + return JSON.parse(readFileSync(join(folder, name), 'utf8')); + } catch (error) { + if (error instanceof SyntaxError) { + throw new SyntaxError(`${where}: ${error.message}`, { cause: error }); + } + throw error; + } +} + +function sortedNames(folder: string): string[] { + return readdirSync(folder).sort(); } function objectIn(value: unknown, where: string): JsonObject { @@ -73,6 +88,14 @@ function field( } const isNumber = (value: unknown): value is number => typeof value === 'number'; +const isIntegerFrom = + (low: number, high: number) => + (value: unknown): value is number => + Number.isInteger(value) && (value as number) >= low && (value as number) <= high; +const isStatus = isIntegerFrom(100, 599); +const isDelay = isIntegerFrom(0, Number.MAX_SAFE_INTEGER); +const isCount = isIntegerFrom(1, Number.MAX_SAFE_INTEGER); +const isLevel = isIntegerFrom(1, 5); const isText = (value: unknown): value is string => typeof value === 'string'; const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'; const isTextList = (value: unknown): value is string[] => @@ -90,16 +113,19 @@ function textRecord(value: unknown, where: string): Readonly { const fixtures = new Map(); - const groups = readdirSync(folder, { withFileTypes: true }).filter((entry) => - entry.isDirectory(), - ); + const groups = readdirSync(folder, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); for (const group of groups) { - for (const file of readdirSync(join(folder, group.name)).filter((name) => - name.endsWith('.json'), - )) { - const name = `${group.name}/${file.slice(0, -'.json'.length)}`; - const record = objectIn(readJson(folder, `${name}.json`), `fixtures/${name}.json`); - const status = field(record, 'status', `fixtures/${name}.json`, isNumber); + for (const file of sortedNames(join(folder, group)).filter((n) => n.endsWith('.json'))) { + const name = `${group}/${file.slice(0, -'.json'.length)}`; + const where = `fixtures/${name}.json`; + const record = objectIn(readJson(folder, `${name}.json`, where), where); + const status = field(record, 'status', where, isStatus); + if (!('body' in record)) { + throw new TypeError(`${where}: the field body is missing.`); + } fixtures.set(name, { status, body: record['body'] }); } } @@ -108,21 +134,29 @@ function readFixtures(folder: string): Map { function readKeys(folder: string): { keys: Map; requestsPerMinute: number } { const where = 'fixtures/keys.json'; - const document = objectIn(readJson(folder, 'keys.json'), where); + const document = objectIn(readJson(folder, 'keys.json', where), where); const keys = new Map(); - for (const entry of listIn(document['keys'], where)) { - const record = objectIn(entry, where); - const key = field(record, 'key', where, isText); + const entries: string[] = []; + for (const [index, entry] of listIn(document['keys'], where).entries()) { + const here = `${where} (entry ${String(index + 1)})`; + const record = objectIn(entry, here); + const key = field(record, 'key', here, isText); + const first = entries.indexOf(key); + if (first !== -1) { + const entry = `entry ${String(first + 1)} and in entry ${String(index + 1)}`; + throw new TypeError(`${where}: the key ${key} is in ${entry}.`); + } + entries.push(key); keys.set(key, { key, - lookupLevel: field(record, 'lookupLevel', where, isNumber), - lookupScope: field(record, 'lookupScope', where, isBoolean), - credits: field(record, 'credits', where, isBoolean), - rateLimited: field(record, 'rateLimited', where, isBoolean), - origins: field(record, 'origins', where, isTextListOrNull), + lookupLevel: field(record, 'lookupLevel', here, isLevel), + lookupScope: field(record, 'lookupScope', here, isBoolean), + credits: field(record, 'credits', here, isBoolean), + rateLimited: field(record, 'rateLimited', here, isBoolean), + origins: field(record, 'origins', here, isTextListOrNull), }); } - return { keys, requestsPerMinute: field(document, 'requestsPerMinute', where, isNumber) }; + return { keys, requestsPerMinute: field(document, 'requestsPerMinute', where, isCount) }; } function scenarioReply( @@ -131,8 +165,16 @@ function scenarioReply( where: string, ): Reply { const response = objectIn(value, where); + const kinds = RESPONSE_KINDS.filter((kind) => response[kind] !== undefined); + if (kinds.length !== 1) { + const problem = + kinds.length === 0 + ? 'has no body, text, fixture, hang or drop' + : `has more than one of ${kinds.join(', ')}`; + throw new TypeError(`${where}: the response ${problem}.`); + } const delayMs = - response['delayMs'] === undefined ? 0 : field(response, 'delayMs', where, isNumber); + response['delayMs'] === undefined ? 0 : field(response, 'delayMs', where, isDelay); if (response['hang'] === true) { return { kind: 'hang' }; } @@ -147,7 +189,7 @@ function scenarioReply( } return { ...jsonReply(fixture.status, fixture.body, headers), delayMs }; } - const status = field(response, 'status', where, isNumber); + const status = field(response, 'status', where, isStatus); if (typeof response['text'] === 'string') { const textHeaders = { 'Content-Type': 'text/plain; charset=utf-8', ...headers }; return { kind: 'send', status, headers: textHeaders, body: response['text'], delayMs }; @@ -160,14 +202,26 @@ function readScenarios( fixtures: ReadonlyMap, ): Map { const scenarios = new Map(); - for (const file of readdirSync(folder).filter((name) => name.endsWith('.json'))) { + const files = new Map(); + for (const file of sortedNames(folder).filter((name) => name.endsWith('.json'))) { const where = `contract/${file}`; - const scenario = objectIn(readJson(folder, file), where); + const scenario = objectIn(readJson(folder, file, where), where); const id = field(scenario, 'id', where, isText); + const earlier = files.get(id); + if (earlier !== undefined) { + throw new TypeError(`${where}: the id ${id} is already used by ${earlier}.`); + } + const stem = file.slice(0, -'.json'.length); + if (id !== stem) { + throw new TypeError(`${where}: the id ${id} must equal the file name ${stem}.`); + } + files.set(id, where); const responses = listIn(scenario['responses'], where); scenarios.set( id, - responses.map((response) => scenarioReply(response, fixtures, where)), + responses.map((response, index) => + scenarioReply(response, fixtures, `${where} (response ${String(index + 1)})`), + ), ); } return scenarios; @@ -175,7 +229,7 @@ function readScenarios( function readSegments(folder: string): SegmentRule[] { const where = 'data/format.json'; - const document = objectIn(readJson(folder, 'format.json'), where); + const document = objectIn(readJson(folder, 'format.json', where), where); let start = 0; return listIn(document['segments'], where).map((entry) => { const record = objectIn(entry, where); @@ -196,7 +250,7 @@ function readSegments(folder: string): SegmentRule[] { function readStates(folder: string): string[] { const where = 'data/states.json'; - const document = objectIn(readJson(folder, 'states.json'), where); + const document = objectIn(readJson(folder, 'states.json', where), where); return listIn(document['states'], where).map((entry) => field(objectIn(entry, where), 'code', where, isText), ); diff --git a/packages/mock-server/test/spec-files.test.ts b/packages/mock-server/test/spec-files.test.ts index bff9f9b..e948eb1 100644 --- a/packages/mock-server/test/spec-files.test.ts +++ b/packages/mock-server/test/spec-files.test.ts @@ -99,12 +99,142 @@ describe('readSpecFiles', () => { const scenario = { id: 'probe', responses: [{ fixture: 'lookup/valid-level-9' }] }; const folder = specWith('contract/probe.json', JSON.stringify(scenario)); expect(() => readSpecFiles(folder)).toThrow( - 'contract/probe.json: no fixture is named lookup/valid-level-9.', + 'contract/probe.json (response 1): no fixture is named lookup/valid-level-9.', ); }); - it('stops at a key file that is not JSON', () => { - expect(() => readSpecFiles(specWith('fixtures/keys.json', '{'))).toThrow(SyntaxError); + it.each([ + ['contract/probe.json'], + ['fixtures/lookup/probe.json'], + ['data/states.json'], + ['fixtures/keys.json'], + ])('stops at %s when it is not JSON, and names the file', (file) => { + const run = (): unknown => readSpecFiles(specWith(file, '{')); + expect(run).toThrow(SyntaxError); + expect(run).toThrow(new RegExp(`^${file}: .*JSON`)); + }); + + it.each([ + ['no kind field', { status: 200 }, 'the response has no body, text, fixture, hang or drop.'], + [ + 'two kind fields', + { hang: true, drop: true }, + 'the response has more than one of hang, drop.', + ], + [ + 'a fixture and a body', + { fixture: 'lookup/invalid', body: {} }, + 'the response has more than one of body, fixture.', + ], + ])('stops at a response with %s, and names the file and the response', (_name, bad, why) => { + const scenario = { id: 'probe', responses: [{ fixture: 'lookup/invalid' }, bad] }; + const folder = specWith('contract/probe.json', JSON.stringify(scenario)); + expect(() => readSpecFiles(folder)).toThrow(`contract/probe.json (response 2): ${why}`); + }); + + it('stops at a fixture with no body, and names the file', () => { + const folder = specWith('fixtures/lookup/probe.json', '{"status": 200}'); + expect(() => readSpecFiles(folder)).toThrow( + 'fixtures/lookup/probe.json: the field body is missing.', + ); + }); + + it('stops at a scenario id that another file already uses, and names both files', () => { + const scenario = { id: 'lookup-timeout', responses: [] }; + const folder = specWith('contract/probe.json', JSON.stringify(scenario)); + expect(() => readSpecFiles(folder)).toThrow( + 'contract/probe.json: the id lookup-timeout is already used by contract/lookup-timeout.json.', + ); + }); + + it('stops at a scenario whose id is not its file name', () => { + const folder = specWith('contract/probe.json', '{"id": "other", "responses": []}'); + expect(() => readSpecFiles(folder)).toThrow( + 'contract/probe.json: the id other must equal the file name probe.', + ); + }); + + it('stops at a key that appears twice, and says which entries', () => { + const keys = JSON.parse(readFileSync(join(SPEC_DIR, 'fixtures/keys.json'), 'utf8')) as { + keys: { key: string }[]; + }; + keys.keys.push({ ...keys.keys[0]! }); + const folder = specWith('fixtures/keys.json', JSON.stringify(keys)); + expect(() => readSpecFiles(folder)).toThrow( + `fixtures/keys.json: the key ${keys.keys[0]!.key} is in entry 1 and in entry 8.`, + ); + }); + + it('says which key entry has a wrong field', () => { + const keys = JSON.parse(readFileSync(join(SPEC_DIR, 'fixtures/keys.json'), 'utf8')) as { + keys: Record[]; + }; + keys.keys[2]!['lookupLevel'] = 'three'; + const folder = specWith('fixtures/keys.json', JSON.stringify(keys)); + expect(() => readSpecFiles(folder)).toThrow( + 'fixtures/keys.json (entry 3): the field lookupLevel has the wrong type.', + ); + }); + + it.each([ + ['a status of 99999', { status: 99999, body: {} }, 'status'], + ['a status of 200.5', { status: 200.5, body: {} }, 'status'], + ['a negative delay', { status: 200, body: {}, delayMs: -5 }, 'delayMs'], + ])('stops at a response with %s', (_name, response, name) => { + const folder = specWith( + 'contract/probe.json', + JSON.stringify({ id: 'probe', responses: [response] }), + ); + expect(() => readSpecFiles(folder)).toThrow( + `contract/probe.json (response 1): the field ${name} has the wrong type.`, + ); + }); + + it.each([ + ['requestsPerMinute', -3], + ['requestsPerMinute', 1.5], + ])('stops at %s of %s in the key file', (name, value) => { + const keys = JSON.parse(readFileSync(join(SPEC_DIR, 'fixtures/keys.json'), 'utf8')) as object; + const folder = specWith('fixtures/keys.json', JSON.stringify({ ...keys, [name]: value })); + expect(() => readSpecFiles(folder)).toThrow( + `fixtures/keys.json: the field ${name} has the wrong type.`, + ); + }); + + it('stops at a key with a level above 5', () => { + const keys = JSON.parse(readFileSync(join(SPEC_DIR, 'fixtures/keys.json'), 'utf8')) as { + keys: Record[]; + }; + keys.keys[0]!['lookupLevel'] = 9; + const folder = specWith('fixtures/keys.json', JSON.stringify(keys)); + expect(() => readSpecFiles(folder)).toThrow('(entry 1): the field lookupLevel'); + }); + + it('stops at a scenario file that is not an object, and at responses that are not a list', () => { + expect(() => readSpecFiles(specWith('contract/probe.json', '[]'))).toThrow( + 'contract/probe.json must be a JSON object.', + ); + const bad = '{"id": "probe", "responses": {}}'; + expect(() => readSpecFiles(specWith('contract/probe.json', bad))).toThrow( + 'contract/probe.json must be a JSON array.', + ); + }); + + it('stops at a header whose value is not text', () => { + const response = { status: 200, body: {}, headers: { 'Retry-After': 1 } }; + const folder = specWith( + 'contract/probe.json', + JSON.stringify({ id: 'probe', responses: [response] }), + ); + expect(() => readSpecFiles(folder)).toThrow('(response 1): the field Retry-After'); + }); + + it('lists scenarios and fixtures in the sorted order of their file names', () => { + const byFile = (a: string, b: string): number => (`${a}.json` < `${b}.json` ? -1 : 1); + const ids = [...FILES.scenarios.keys()]; + expect(ids).toEqual([...ids].sort(byFile)); + const names = [...FILES.fixtures.keys()]; + expect(names).toEqual([...names].sort(byFile)); }); }); @@ -125,4 +255,10 @@ describe('fixtureReply', () => { 'The spec has no fixture lookup/valid-level-9.', ); }); + + it('fails when a change meets a body with no data', () => { + expect(() => fixtureReply(FILES, 'errors/rate-limited', { postcode: 'X' })).toThrow( + 'fixtures/errors/rate-limited.json must be a JSON object.', + ); + }); }); diff --git a/scripts/eslint-mock-server.test.mjs b/scripts/eslint-mock-server.test.mjs index a25dd9b..5ba1b3f 100644 --- a/scripts/eslint-mock-server.test.mjs +++ b/scripts/eslint-mock-server.test.mjs @@ -44,6 +44,14 @@ describe('the lint config in the mock server', () => { assert.deepEqual(found, []); }); + it('allows the process global through globalThis', async () => { + const found = await ruleIds( + '/** The port that PORT names. */', + 'export const port: string | undefined = globalThis.process.env.PORT;', + ); + assert.deepEqual(found, []); + }); + it('still rejects a console call, as TS-12 says', async () => { const found = await ruleIds( '/** Says hello. */', From 573a298bacd06e2be09a8e75f541905d5776c97b Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 01:39:35 +0100 Subject: [PATCH 05/14] chore(repo): update the spec submodule The spec now plays 70 scenarios, adds the lookup-no-data scenario and changes the confidence of the reverse unit fixture to low. The search test expected the old value, so it follows the fixture. The truth check of the spec also plays each scenario as the client contract describes. Signed-off-by: MobileMage --- packages/mock-server/test/search.test.ts | 2 +- spec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mock-server/test/search.test.ts b/packages/mock-server/test/search.test.ts index d4771b0..f23ba96 100644 --- a/packages/mock-server/test/search.test.ts +++ b/packages/mock-server/test/search.test.ts @@ -23,7 +23,7 @@ describe('reverse', () => { postcode: 'FC-01-Z99-ZZ-01', display: 'FC 01 Z99 ZZ 01', distance_m: 4.2, - confidence: 'high', + confidence: 'low', }, area: 'FC-01-Z99-ZZ', district: 'FC-01-Z99', diff --git a/spec b/spec index 8ab969f..16b5a5b 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 8ab969fcc34cf1ef0e54b01e0fe07bac217de8f4 +Subproject commit 16b5a5ba38fe18c0bc447f299aff37d5b7a70df6 From c8898db24d2e6fee3fcf9b3a9c12663324de5907 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 01:42:47 +0100 Subject: [PATCH 06/14] feat(mock): serve over HTTP and play the contract scenarios A client test sends X-Scenario-Id and X-Scenario-Run, and gets the next response of that run. A run keeps its own place, so two test runs never share a response. The server holds a connection open for a hang and closes it for a drop, as the timeout and network scenarios need, and it closes every open connection when a test ends. The command starts it on Node with no build. Signed-off-by: MobileMage --- packages/mock-server/src/index.ts | 9 + packages/mock-server/src/main.ts | 40 ++++ packages/mock-server/src/scenarios.ts | 36 ++++ packages/mock-server/src/server.ts | 151 ++++++++++++++ packages/mock-server/test/main.test.ts | 75 +++++++ packages/mock-server/test/scenarios.test.ts | 81 ++++++++ packages/mock-server/test/server.test.ts | 212 ++++++++++++++++++++ 7 files changed, 604 insertions(+) create mode 100644 packages/mock-server/src/index.ts create mode 100644 packages/mock-server/src/main.ts create mode 100644 packages/mock-server/src/scenarios.ts create mode 100644 packages/mock-server/src/server.ts create mode 100644 packages/mock-server/test/main.test.ts create mode 100644 packages/mock-server/test/scenarios.test.ts create mode 100644 packages/mock-server/test/server.test.ts diff --git a/packages/mock-server/src/index.ts b/packages/mock-server/src/index.ts new file mode 100644 index 0000000..f77b89a --- /dev/null +++ b/packages/mock-server/src/index.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +/** + * Unofficial. Not made or endorsed by NIPOST. A local mock of NIPOST's postcode gateway, for + * the tests of Gatepost's clients. + * + * @packageDocumentation + */ +export { startMockServer, type MockServer, type MockServerOptions } from './server.ts'; diff --git a/packages/mock-server/src/main.ts b/packages/mock-server/src/main.ts new file mode 100644 index 0000000..53f3630 --- /dev/null +++ b/packages/mock-server/src/main.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import process from 'node:process'; +import { startMockServer, type MockServerOptions } from './server.ts'; + +function wholeNumber(name: string, fallback: number): number { + const raw = process.env[name] ?? ''; + const parsed = Number(raw); + if (raw === '') { + return fallback; + } + if (!Number.isInteger(parsed) || parsed < 0) { + throw new RangeError(`${name} must be a whole number of 0 or more. It is ${raw}.`); + } + return parsed; +} + +function optionsFromEnvironment(): MockServerOptions { + const specDir = process.env['GATEPOST_SPEC_DIR'] ?? ''; + return { + port: wholeNumber('PORT', 4010), + host: process.env['HOST'] ?? '127.0.0.1', + delayMs: wholeNumber('MOCK_DELAY_MS', 0), + ...(specDir === '' ? {} : { specDir }), + }; +} + +try { + const server = await startMockServer(optionsFromEnvironment()); + process.stdout.write(`The Gatepost mock server listens on ${server.url}\n`); + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + void server.close(); + }); + } +} catch (error: unknown) { + // A bad setting or a broken spec file ends the program with a message and no stack trace. + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 2; +} diff --git a/packages/mock-server/src/scenarios.ts b/packages/mock-server/src/scenarios.ts new file mode 100644 index 0000000..485b95d --- /dev/null +++ b/packages/mock-server/src/scenarios.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { jsonReply, type Reply } from './reply.ts'; + +function mockError(code: string, message: string): Reply { + return jsonReply(400, { error: { code, message } }); +} + +/** + * Plays the responses of the contract scenarios. Each run of a scenario keeps its own place + * in the list of responses, and the last response repeats. + * + * @param scenarios - The responses of each scenario, by its id. + * @returns A function that takes the values of `X-Scenario-Id` and `X-Scenario-Run`, and + * returns the next reply of that run. + * @internal + */ +export function createScenarioPlayer( + scenarios: ReadonlyMap, +): (id: string, run: string | undefined) => Reply { + const positions = new Map(); + return (id, run) => { + const replies = scenarios.get(id); + if (replies === undefined) { + return mockError('mock_unknown_scenario', `The mock server has no scenario ${id}.`); + } + if (run === undefined || run === '') { + return mockError('mock_run_missing', 'A scenario request needs the X-Scenario-Run header.'); + } + const place = JSON.stringify([id, run]); + const position = positions.get(place) ?? 0; + positions.set(place, position + 1); + const reply = replies[Math.min(position, replies.length - 1)]; + return reply ?? mockError('mock_no_request', `The scenario ${id} expects no request.`); + }; +} diff --git a/packages/mock-server/src/server.ts b/packages/mock-server/src/server.ts new file mode 100644 index 0000000..05f38d5 --- /dev/null +++ b/packages/mock-server/src/server.ts @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { createGateway } from './gateway.ts'; +import { jsonReply, type MockRequest, type Reply } from './reply.ts'; +import { createScenarioPlayer } from './scenarios.ts'; +import { readSpecFiles } from './spec-files.ts'; + +/** Options for {@link startMockServer}. */ +export interface MockServerOptions { + /** The port. 0 picks a free port. The default is 4010. */ + readonly port?: number; + /** The address to listen on. The default is 127.0.0.1. */ + readonly host?: string; + /** A wait in milliseconds before every response, for timeout tests. The default is 0. */ + readonly delayMs?: number; + /** The spec folder. The default is the `spec` submodule of the js repo. */ + readonly specDir?: string; + /** The clock of the rate limit, in milliseconds since the epoch. The default is `Date.now`. */ + readonly now?: () => number; +} + +/** A running mock server. */ +export interface MockServer { + /** The base URL, such as `http://127.0.0.1:4010`. */ + readonly url: string; + /** Closes every connection, also the ones that wait for an answer, and stops the server. */ + close(): Promise; +} + +// The gateway sends these on every response. The mock server also allows the two headers of a +// contract scenario, so that a browser test can send them. +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': + 'Authorization, Content-Type, X-API-Key, X-Widget-Origin, X-Scenario-Id, X-Scenario-Run', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Max-Age': '300', +}; +const DEFAULT_SPEC_DIR = fileURLToPath(new URL('../../../spec/', import.meta.url)); + +async function readRequest(incoming: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of incoming) { + chunks.push(chunk as Buffer); + } + const url = new URL(incoming.url ?? '/', 'http://mock.invalid'); + const headers: Record = {}; + for (const [name, value] of Object.entries(incoming.headers)) { + if (value !== undefined) { + headers[name] = Array.isArray(value) ? value.join(', ') : value; + } + } + return { + method: incoming.method ?? 'GET', + path: url.pathname, + query: url.searchParams, + headers, + body: Buffer.concat(chunks).toString('utf8'), + }; +} + +async function send( + incoming: IncomingMessage, + outgoing: ServerResponse, + reply: Reply, + extraMs: number, +): Promise { + if (reply.kind === 'hang') { + return; + } + await sleep(reply.delayMs + extraMs); + if (reply.kind === 'drop') { + incoming.socket.destroy(); + return; + } + const length = String(Buffer.byteLength(reply.body)); + outgoing.writeHead(reply.status, { ...CORS_HEADERS, 'Content-Length': length, ...reply.headers }); + outgoing.end(reply.body); +} + +function listen(server: Server, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => { + const address = server.address(); + resolve(typeof address === 'object' && address !== null ? address.port : port); + }); + }); +} + +/** + * Starts the mock server. A request with an `X-Scenario-Id` header gets the next response of + * that contract scenario. Any other request gets the gateway's answer from the fixtures. + * + * @param options - The port, the address, an extra delay, the spec folder and the clock. + * @returns The running server. + * @throws TypeError when a file in the spec folder has the wrong shape. + * + * @example + * ```ts + * import { startMockServer } from '@gatepost/mock-server'; + * + * const mock = await startMockServer({ port: 0 }); + * const response = await fetch(`${mock.url}/v1/lookup?code=FC-01-Z99-ZZ-01`, { + * headers: { 'X-API-Key': 'nipost_test_mock_l1' }, + * }); + * await response.json(); // { data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, ... } } + * await mock.close(); + * ``` + */ +export async function startMockServer(options: MockServerOptions = {}): Promise { + const files = readSpecFiles(options.specDir ?? DEFAULT_SPEC_DIR); + const gateway = createGateway(files, options.now ?? Date.now); + const play = createScenarioPlayer(files.scenarios); + const extraMs = options.delayMs ?? 0; + const choose = (request: MockRequest): Reply => { + const scenario = request.headers['x-scenario-id']; + return request.method === 'OPTIONS' || scenario === undefined + ? gateway(request) + : play(scenario, request.headers['x-scenario-run']); + }; + const server = createServer((incoming, outgoing) => { + readRequest(incoming) + .then((request) => send(incoming, outgoing, choose(request), extraMs)) + .catch((error: unknown) => { + // A broken fixture is a bug in the spec. The test that hit it sees the message. + const message = error instanceof Error ? error.message : String(error); + const reply = jsonReply(500, { error: { code: 'mock_error', message } }); + return send(incoming, outgoing, reply, 0); + }); + }); + const host = options.host ?? '127.0.0.1'; + const port = await listen(server, options.port ?? 4010, host); + return { + url: `http://${host === '0.0.0.0' ? 'localhost' : host}:${String(port)}`, + close: () => + new Promise((resolve, reject) => { + server.closeAllConnections(); + server.close((error) => { + if (error === undefined) { + resolve(); + } else { + reject(error); + } + }); + }), + }; +} diff --git a/packages/mock-server/test/main.test.ts b/packages/mock-server/test/main.test.ts new file mode 100644 index 0000000..a2d5bf0 --- /dev/null +++ b/packages/mock-server/test/main.test.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// These tests start src/main.ts as Node starts it in the container: with no build, through +// Node's type stripping. A syntax that Node cannot strip fails here. +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const MAIN = fileURLToPath(new URL('../src/main.ts', import.meta.url)); + +interface Run { + readonly code: number | null; + readonly stdout: string; + readonly stderr: string; +} + +// Starts the command, calls `whileRunning` with its first line of output, then stops it. +async function run( + env: Record, + whileRunning: (line: string) => Promise = () => Promise.resolve(), +): Promise { + const child = spawn(process.execPath, [MAIN], { env: { ...process.env, ...env } }); + let stdout = ''; + let stderr = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const exited = once(child, 'exit'); + const started = new Promise((resolve) => { + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + if (stdout.includes('\n')) { + resolve(); + } + }); + }); + await Promise.race([started, exited]); + if (stdout.includes('\n')) { + await whileRunning(stdout.trim()); + child.kill('SIGTERM'); + } + const [code] = (await exited) as [number | null]; + return { code, stdout, stderr }; +} + +describe('the mock server command', () => { + it('serves on the port that PORT names, and stops on SIGTERM', async () => { + let health = 0; + const result = await run({ PORT: '0' }, async (line) => { + const url = /http:\/\/\S+/.exec(line)?.[0] ?? ''; + health = (await fetch(`${url}/healthz`)).status; + }); + expect(result.stdout).toMatch( + /^The Gatepost mock server listens on http:\/\/127\.0\.0\.1:\d+\n$/, + ); + expect([health, result.code, result.stderr]).toEqual([200, 0, '']); + }); + + it.each([ + ['PORT', 'abc'], + ['MOCK_DELAY_MS', '-5'], + ])('stops with exit code 2 when %s is %s', async (name, value) => { + const result = await run({ PORT: '0', [name]: value }); + expect(result.code).toBe(2); + expect(result.stderr).toBe(`${name} must be a whole number of 0 or more. It is ${value}.\n`); + }); + + it('stops with exit code 2 when the spec folder does not exist', async () => { + const result = await run({ PORT: '0', GATEPOST_SPEC_DIR: '/no/such/spec' }); + expect(result.code).toBe(2); + expect(result.stderr).toContain('/no/such/spec/fixtures'); + }); +}); diff --git a/packages/mock-server/test/scenarios.test.ts b/packages/mock-server/test/scenarios.test.ts new file mode 100644 index 0000000..103f109 --- /dev/null +++ b/packages/mock-server/test/scenarios.test.ts @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createScenarioPlayer } from '../src/scenarios.ts'; +import { errorCode, FILES, SPEC_DIR, sent } from './mock-request.ts'; + +interface ScenarioResponse { + readonly status?: number; + readonly fixture?: string; + readonly hang?: true; + readonly drop?: true; +} + +const CONTRACT = join(SPEC_DIR, 'contract'); +const scenarios = readdirSync(CONTRACT) + .filter((name) => name.endsWith('.json')) + .map( + (name) => + JSON.parse(readFileSync(join(CONTRACT, name), 'utf8')) as { + id: string; + responses: ScenarioResponse[]; + }, + ); + +// The kind and the status of the reply that the spec file asks for. +function expected(response: ScenarioResponse): [string, number | null] { + if (response.hang === true) { + return ['hang', null]; + } + if (response.drop === true) { + return ['drop', null]; + } + const fixture = response.fixture === undefined ? undefined : FILES.fixtures.get(response.fixture); + return ['send', fixture?.status ?? response.status ?? null]; +} + +describe('the scenario player', () => { + it('knows each scenario file of the spec', () => { + expect([...FILES.scenarios.keys()].sort()).toEqual(scenarios.map(({ id }) => id).sort()); + }); + + it.each(scenarios.filter(({ responses }) => responses.length > 0))( + 'plays the responses of $id in order, then repeats the last', + ({ id, responses }) => { + const play = createScenarioPlayer(FILES.scenarios); + const asked = [...responses, ...responses.slice(-1)].map(expected); + const played = asked.map(() => { + const reply = play(id, 'run-1'); + return [reply.kind, reply.kind === 'send' ? reply.status : null]; + }); + expect(played).toEqual(asked); + }, + ); + + it('keeps a place for each run, so a test can run a scenario again', () => { + const play = createScenarioPlayer(FILES.scenarios); + expect(sent(play('lookup-retry-503', 'run-1')).status).toBe(503); + expect(sent(play('lookup-retry-503', 'run-1')).status).toBe(200); + expect(sent(play('lookup-retry-503', 'run-2')).status).toBe(503); + }); + + it('answers a scenario that it does not know with 400', () => { + const response = sent(createScenarioPlayer(FILES.scenarios)('lookup-retry-999', 'run-1')); + expect([response.status, errorCode(response)]).toEqual([400, 'mock_unknown_scenario']); + }); + + it('answers a scenario request with no run with 400', () => { + const play = createScenarioPlayer(FILES.scenarios); + for (const run of [undefined, '']) { + const response = sent(play('lookup-retry-503', run)); + expect([response.status, errorCode(response)]).toEqual([400, 'mock_run_missing']); + } + }); + + it('answers a request in a scenario that expects none with 400', () => { + const response = sent(createScenarioPlayer(FILES.scenarios)('lookup-invalid-input', 'run-1')); + expect([response.status, errorCode(response)]).toEqual([400, 'mock_no_request']); + }); +}); diff --git a/packages/mock-server/test/server.test.ts b/packages/mock-server/test/server.test.ts new file mode 100644 index 0000000..22d4638 --- /dev/null +++ b/packages/mock-server/test/server.test.ts @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// These tests start the server on a free port of the loopback address, and call it with fetch. +// They cover what only a real connection shows: headers, delays, held and dropped connections. +import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { startMockServer, type MockServer, type MockServerOptions } from '../src/index.ts'; +import { FILES, SPEC_DIR } from './mock-request.ts'; + +const KEY = { 'X-API-Key': 'nipost_test_mock_l1' }; +const servers: MockServer[] = []; +const folders: string[] = []; + +async function start(options: MockServerOptions = {}): Promise { + const server = await startMockServer({ port: 0, ...options }); + servers.push(server); + return server; +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())); + for (const folder of folders.splice(0)) { + rmSync(folder, { recursive: true }); + } +}); + +interface FileResponse { + readonly status?: number; + readonly body?: unknown; + readonly text?: string; + readonly fixture?: string; + readonly headers?: Record; + readonly hang?: true; + readonly drop?: true; +} + +const CONTRACT = join(SPEC_DIR, 'contract'); +const contract = readdirSync(CONTRACT) + .filter((name) => name.endsWith('.json')) + .map( + (name) => + JSON.parse(readFileSync(join(CONTRACT, name), 'utf8')) as { + id: string; + responses: FileResponse[]; + }, + ); + +// Checks the response that arrived against the spec file. +async function expectAnswer(answer: Response, spec: FileResponse): Promise { + const fixture = spec.fixture === undefined ? undefined : FILES.fixtures.get(spec.fixture); + const text = await answer.text(); + expect(answer.status).toBe(fixture?.status ?? spec.status); + expect(answer.headers.get('access-control-allow-origin')).toBe('*'); + for (const [name, value] of Object.entries(spec.headers ?? {})) { + expect(answer.headers.get(name)).toBe(value); + } + if (spec.text !== undefined) { + expect(text).toBe(spec.text); + return; + } + expect(JSON.parse(text)).toEqual(fixture === undefined ? spec.body : fixture.body); + expect(answer.headers.get('content-type')).toBe( + spec.headers?.['Content-Type'] ?? 'application/json', + ); +} + +// Asks for one response of a scenario. A hang and a drop give no response. +async function expectResponse( + url: string, + headers: Record, + spec: FileResponse, +): Promise { + const call = fetch(`${url}/v1/lookup?code=FC01Z99ZZ01`, { + headers, + signal: AbortSignal.timeout(spec.hang === true ? 150 : 5000), + }); + if (spec.hang === true) { + await expect(call).rejects.toThrow('timeout'); + } else if (spec.drop === true) { + await expect(call).rejects.toThrow(TypeError); + } else { + await expectAnswer(await call, spec); + } +} + +describe('the contract scenarios over HTTP', () => { + // A client that follows the contract sends one request for each response in the file, and + // more requests get the last response again. The test sends one request more than that. + it.each(contract)('sends the responses of $id, as the spec file lists them', async (scenario) => { + const { url } = await start(); + const headers = { 'X-Scenario-Id': scenario.id, 'X-Scenario-Run': 'run-1' }; + for (const spec of [...scenario.responses, ...scenario.responses.slice(-1)]) { + await expectResponse(url, headers, spec); + } + }); + + it('refuses a request for a scenario that expects none', async () => { + const { url } = await start(); + const headers = { 'X-Scenario-Id': 'lookup-invalid-input', 'X-Scenario-Run': 'run-1' }; + expect((await fetch(`${url}/v1/lookup`, { headers })).status).toBe(400); + }); + + it('keeps the place of each run apart over HTTP', async () => { + const { url } = await start(); + const send = async (run: string | null): Promise => { + const headers: Record = { 'X-Scenario-Id': 'lookup-retry-503' }; + if (run !== null) { + headers['X-Scenario-Run'] = run; + } + return (await fetch(`${url}/v1/lookup`, { headers })).status; + }; + expect([await send('a'), await send('b'), await send('a'), await send(null)]).toEqual([ + 503, 503, 200, 400, + ]); + }); +}); + +describe('the mock server', () => { + it('answers with the gateway headers and a body that ends with a line feed', async () => { + const { url } = await start(); + const response = await fetch(`${url}/v1/lookup?code=FC-01-Z99-ZZ-01`, { headers: KEY }); + const text = await response.text(); + expect(response.status).toBe(200); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); + expect(response.headers.get('content-type')).toBe('application/json'); + expect(response.headers.get('content-length')).toBe(String(text.length)); + expect(response.headers.get('x-ratelimit-remaining')).toBe('599'); + expect(text.endsWith('}\n')).toBe(true); + }); + + it('allows the scenario headers in a browser preflight', async () => { + const { url } = await start(); + const response = await fetch(`${url}/v1/lookup`, { method: 'OPTIONS' }); + expect(response.status).toBe(204); + expect(response.headers.get('access-control-allow-headers')).toContain('X-Scenario-Run'); + }); + + it('reads the body of a POST request', async () => { + const { url } = await start(); + const segments = { state: 'fc', lga: '1', district: 'z99', area: 'zz', unit: '1' }; + const response = await fetch(`${url}/v1/assembly/assemble`, { + method: 'POST', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify(segments), + }); + expect(await response.json()).toEqual({ + data: { compact: 'FC01Z99ZZ01', display: 'FC 01 Z99 ZZ 01', postcode: 'FC-01-Z99-ZZ-01' }, + }); + }); + + it('plays a scenario for a request with the scenario headers', async () => { + const { url } = await start(); + const headers = { 'X-Scenario-Id': 'lookup-retry-502', 'X-Scenario-Run': 'run-1' }; + const statuses: number[] = []; + for (let attempt = 0; attempt < 2; attempt += 1) { + statuses.push((await fetch(`${url}/v1/lookup?code=FC01Z99ZZ01`, { headers })).status); + } + expect(statuses).toEqual([502, 200]); + }); + + it('closes the connection with no response for a drop', async () => { + const { url } = await start(); + const headers = { 'X-Scenario-Id': 'lookup-network-error', 'X-Scenario-Run': 'run-1' }; + await expect(fetch(`${url}/v1/lookup`, { headers })).rejects.toThrow(TypeError); + }); + + it('holds an empty autocomplete until the client gives up, and still closes', async () => { + const server = await start(); + const signal = AbortSignal.timeout(100); + const call = fetch(`${server.url}/v1/search/autocomplete?q=`, { headers: KEY, signal }); + await expect(call).rejects.toThrow('The operation was aborted due to timeout'); + }); + + it('closes while a request still waits for an answer', async () => { + const server = await startMockServer({ port: 0 }); + const call = fetch(`${server.url}/v1/search/autocomplete?q=`, { headers: KEY }); + const failed = expect(call).rejects.toThrow(TypeError); + await new Promise((resolve) => setTimeout(resolve, 50)); + await server.close(); + await failed; + }); + + it('waits the extra delay before every response', async () => { + const { url } = await start({ delayMs: 150 }); + const started = performance.now(); + await fetch(`${url}/healthz`); + expect(performance.now() - started).toBeGreaterThanOrEqual(140); + }); + + it('answers 500 with the reason when a fixture is missing from the spec', async () => { + const folder = mkdtempSync(join(tmpdir(), 'gatepost-spec-')); + folders.push(folder); + for (const part of ['data', 'fixtures', 'contract']) { + cpSync(join(SPEC_DIR, part), join(folder, part), { recursive: true }); + } + rmSync(join(folder, 'fixtures/errors/unknown-path.json')); + const { url } = await start({ specDir: folder }); + const response = await fetch(`${url}/v1/other`, { headers: KEY }); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: { code: 'mock_error', message: 'The spec has no fixture errors/unknown-path.' }, + }); + }); + + it('gives a URL with localhost when it listens on every address', async () => { + const { url } = await start({ host: '0.0.0.0' }); + expect(url).toMatch(/^http:\/\/localhost:\d+$/); + expect((await fetch(`${url}/healthz`)).status).toBe(200); + }); +}); From 80c9170f86763429d59103b211eaac49b4cc06de Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:05:14 +0100 Subject: [PATCH 07/14] fix(mock): report a spent key, keep units in range, pin the headers A rate-limited key now shows no requests left, as the 429 response says. Reverse no longer returns a unit that lies outside the radius. Numbers in a query read as decimal text by value, so a level of 2.0 works and 0x9 does not. The OpenAPI test now compares the headers and the error code of each response with the file, and new tests pin the order of the key, path, origin, scope, level and credit checks. Signed-off-by: MobileMage --- packages/mock-server/src/gateway.ts | 2 +- packages/mock-server/src/lookup.ts | 18 ++++-- packages/mock-server/src/postcodes.ts | 15 +++++ packages/mock-server/src/search.ts | 20 ++++-- packages/mock-server/test/gateway.test.ts | 78 ++++++++++++++++++++++- packages/mock-server/test/lookup.test.ts | 29 +++++++++ packages/mock-server/test/openapi.test.ts | 74 +++++++++++++++++++-- packages/mock-server/test/search.test.ts | 51 ++++++++++++++- 8 files changed, 263 insertions(+), 24 deletions(-) diff --git a/packages/mock-server/src/gateway.ts b/packages/mock-server/src/gateway.ts index 1507119..d3aa5e5 100644 --- a/packages/mock-server/src/gateway.ts +++ b/packages/mock-server/src/gateway.ts @@ -67,7 +67,7 @@ export function createGateway( const left = count(apiKey); return withHeaders(keyedReply(request, files, key, left), { 'X-RateLimit-Limit': String(files.requestsPerMinute), - 'X-RateLimit-Remaining': String(Math.max(left, 0)), + 'X-RateLimit-Remaining': String(key.rateLimited ? 0 : Math.max(left, 0)), }); }; } diff --git a/packages/mock-server/src/lookup.ts b/packages/mock-server/src/lookup.ts index 912df09..609d950 100644 --- a/packages/mock-server/src/lookup.ts +++ b/packages/mock-server/src/lookup.ts @@ -1,11 +1,9 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 -import { compactCode, isWellFormed } from './postcodes.ts'; +import { compactCode, decimalNumber, isWellFormed } from './postcodes.ts'; import type { MockRequest, Reply } from './reply.ts'; import { fixtureData, fixtureReply, type MockKey, type SpecFiles } from './spec-files.ts'; -const LEVEL = /^[1-5]$/; - /** * Gives the compact form of the one unit that the mock server knows: the postcode in the * fixture `lookup/valid-level-1`. @@ -34,6 +32,12 @@ function refusal(key: MockKey, level: number): string | null { return level > 1 && !key.credits ? 'errors/insufficient-credits' : null; } +// A level is a whole number from 1 to 5, read by value, so 2 and 2.0 are the same level. +function levelIn(query: URLSearchParams): number | null { + const level = decimalNumber(query.get('level') ?? '1'); + return level !== null && Number.isInteger(level) && level >= 1 && level <= 5 ? level : null; +} + /** * Answers `GET /v1/lookup`. The body echoes the caller's text in upper case, as the gateway does. * @@ -45,18 +49,18 @@ function refusal(key: MockKey, level: number): string | null { */ export function lookup(request: MockRequest, files: SpecFiles, key: MockKey): Reply { const code = request.query.get('code') ?? ''; - const level = request.query.get('level') ?? '1'; - if (compactCode(code) === '' || !LEVEL.test(level)) { + const level = levelIn(request.query); + if (compactCode(code) === '' || level === null) { return fixtureReply(files, 'errors/invalid-request'); } - const refused = refusal(key, Number(level)); + const refused = refusal(key, level); if (refused !== null) { return fixtureReply(files, refused); } const compact = compactCode(code); let name = isWellFormed(compact, files) ? 'lookup/not-found' : 'lookup/invalid'; if (compact === knownUnit(files)) { - name = `lookup/valid-level-${level}`; + name = `lookup/valid-level-${String(level)}`; } return fixtureReply(files, name, { postcode: code.toUpperCase() }); } diff --git a/packages/mock-server/src/postcodes.ts b/packages/mock-server/src/postcodes.ts index 08f1180..62f3982 100644 --- a/packages/mock-server/src/postcodes.ts +++ b/packages/mock-server/src/postcodes.ts @@ -22,6 +22,21 @@ export function compactCode(text: string): string { return text.toUpperCase().replace(SEPARATORS, ''); } +const DECIMAL = /^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/; + +/** + * Reads a number from decimal text, so that `9`, `9.0` and `9e0` give the same value. Other + * forms that `Number()` accepts, such as `0x9`, are not numbers here. + * + * @param text - The text of a query value. + * @returns The number, or null when the text is not a decimal number. + * @internal + */ +export function decimalNumber(text: string): number | null { + const parsed = DECIMAL.test(text.trim()) ? Number(text) : Number.NaN; + return Number.isFinite(parsed) ? parsed : null; +} + function segmentPasses(rule: SegmentRule, text: string): boolean { const pattern = CHARACTERS.get(rule.characters); return pattern?.test(text) === true && (rule.minimum === null || Number(text) >= rule.minimum); diff --git a/packages/mock-server/src/search.ts b/packages/mock-server/src/search.ts index 03d53ef..6983c4d 100644 --- a/packages/mock-server/src/search.ts +++ b/packages/mock-server/src/search.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 import { knownUnit } from './lookup.ts'; -import { activeSegment, compactCode } from './postcodes.ts'; +import { activeSegment, compactCode, decimalNumber } from './postcodes.ts'; import { jsonReply, type MockRequest, type Reply } from './reply.ts'; import { fixtureData, fixtureReply, type SpecFiles } from './spec-files.ts'; @@ -11,9 +11,7 @@ const PLACES = ['reverse/unit', 'reverse/area'] as const; const CODE_CHARACTERS = /^[A-Z0-9]+$/; function numberIn(query: URLSearchParams, name: string): number | null { - const raw = query.get(name)?.trim() ?? ''; - const parsed = Number(raw); - return raw === '' || !Number.isFinite(parsed) ? null : parsed; + return decimalNumber(query.get(name) ?? ''); } function radiusIn(query: URLSearchParams, name: string, fallback: number): number | null { @@ -29,10 +27,19 @@ function samePlace(files: SpecFiles, name: string, lng: number, lat: number): bo return Array.isArray(coordinate) && coordinate[0] === lng && coordinate[1] === lat; } +// The unit fixture holds the distance to its unit. A radius under it leaves the unit out of range. +function inRange(files: SpecFiles, name: string, radius: number): boolean { + const unit = fixtureData(files, name, 'unit'); + const distance = (unit as { distance_m?: unknown } | undefined)?.distance_m; + return typeof distance !== 'number' || distance <= radius; +} + /** * Answers `GET /v1/search/reverse`. A point that a fixture names gets that fixture. Any other * point gets the fixture for no postcode in range. The body echoes the coordinate as - * [lng, lat] and gives the radius that the mock server applied. + * [lng, lat] and gives the radius that the mock server applied. A unit farther than the radius + * is out of range. The ranges of lat and lng are a client rule, so the mock server accepts + * any value. * * @param request - The request. * @param files - The spec files. @@ -46,8 +53,9 @@ export function reverse(request: MockRequest, files: SpecFiles): Reply { if (lat === null || lng === null || radius === null) { return fixtureReply(files, 'errors/invalid-request'); } - const name = PLACES.find((place) => samePlace(files, place, lng, lat)) ?? 'reverse/not-found'; + const place = PLACES.find((name) => samePlace(files, name, lng, lat)); const radiusM = Math.min(radius, REVERSE_RADIUS_M.maximum); + const name = place === undefined || !inRange(files, place, radiusM) ? 'reverse/not-found' : place; return fixtureReply(files, name, { coordinate: [lng, lat], radius_m: radiusM }); } diff --git a/packages/mock-server/test/gateway.test.ts b/packages/mock-server/test/gateway.test.ts index 3426fdf..fe1f2ba 100644 --- a/packages/mock-server/test/gateway.test.ts +++ b/packages/mock-server/test/gateway.test.ts @@ -47,7 +47,8 @@ describe('the gateway without a scenario', () => { it('answers the request after the limit with 429 and no Retry-After', () => { const gateway = createGateway(FILES, () => 0); for (let count = 0; count < 600; count += 1) { - gateway(mockRequest(LOOKUP, { headers: L3 })); + const counted = sent(gateway(mockRequest(LOOKUP, { headers: L3 }))); + expect([counted.status, remaining(counted)]).toEqual([200, String(599 - count)]); } const response = sent(gateway(mockRequest(LOOKUP, { headers: L3 }))); expect([response.status, errorCode(response), remaining(response)]).toEqual([ @@ -63,6 +64,8 @@ describe('the gateway without a scenario', () => { const response = sent(createGateway(FILES, () => 0)(mockRequest(LOOKUP, { headers }))); expect([response.status, errorCode(response)]).toEqual([429, 'rate_limited']); expect(response.headers['Retry-After']).toBe('1'); + expect(response.headers['X-RateLimit-Limit']).toBe('600'); + expect(remaining(response)).toBe('0'); }); it('refuses a publishable key from an origin that is not on its list, and counts it', () => { @@ -100,4 +103,77 @@ describe('the gateway without a scenario', () => { const post = sent(gateway(mockRequest(LOOKUP, { method: 'POST', headers: L3 }))); expect(post.status).toBe(404); }); + + it('keeps a count inside one clock minute, however late in it the request comes', () => { + let clock = 0; + const gateway = createGateway(FILES, () => clock); + gateway(mockRequest(LOOKUP, { headers: L3 })); + clock = 45_000; + expect(remaining(sent(gateway(mockRequest(LOOKUP, { headers: L3 }))))).toBe('598'); + }); + + it('checks the origin of a publishable key after the path and before the limit', () => { + const gateway = createGateway(FILES, () => 0); + const bad = { 'x-api-key': 'nipost_pk_test_mock', origin: 'http://localhost:3001' }; + const widget = sent(gateway(mockRequest('/v1/widget/lookup', { headers: bad }))); + expect([widget.status, errorCode(widget)]).toEqual([404, 'not_found']); + const good = { 'x-api-key': 'nipost_pk_test_mock', origin: 'http://localhost:3000' }; + for (let count = 0; count < 600; count += 1) { + gateway(mockRequest(LOOKUP, { headers: good })); + } + const refused = sent(gateway(mockRequest(LOOKUP, { headers: bad }))); + expect([refused.status, errorCode(refused)]).toEqual([403, 'origin_not_allowed']); + }); + + it('sends the rate-limit headers on a 400 and on a 404 for a known key', () => { + const gateway = createGateway(FILES, () => 0); + const bad = sent(gateway(mockRequest('/v1/lookup?level=1', { headers: L3 }))); + const missing = sent(gateway(mockRequest('/v1/widget/lookup', { headers: L3 }))); + for (const response of [bad, missing]) { + expect(response.headers['X-RateLimit-Limit']).toBe('600'); + expect(remaining(response)).toMatch(/^\d+$/); + } + }); + + it.each([ + ['/v1/search/reverse?lat=9&lng=7'], + ['/v1/search/nearby?lat=9&lng=7'], + ['/v1/search/autocomplete?q=E'], + ['/v1/assembly/disassemble?code=FC01Z99ZZ01'], + ])('sends the rate-limit headers on %s', (target) => { + const response = sent(createGateway(FILES, () => 0)(mockRequest(target, { headers: L3 }))); + expect(response.headers['X-RateLimit-Limit']).toBe('600'); + expect(remaining(response)).toBe('599'); + }); + + it('sends the rate-limit headers on assemble', () => { + const body = '{"state":"FC","lga":"01","district":"Z99","area":"ZZ","unit":"01"}'; + const request = mockRequest('/v1/assembly/assemble', { method: 'POST', headers: L3, body }); + const response = sent(createGateway(FILES, () => 0)(request)); + expect(response.headers['X-RateLimit-Limit']).toBe('600'); + }); + + it('serves each route with its own method only, and /healthz with GET only', () => { + const gateway = createGateway(FILES, () => 0); + const get = sent(gateway(mockRequest('/v1/assembly/assemble', { headers: L3 }))); + expect(get.status).toBe(404); + expect(errorCode(sent(gateway(mockRequest('/healthz', { method: 'POST' }))))).toBe( + 'auth_required', + ); + }); + + it('checks the key before the path, and treats an empty key as a key it does not know', () => { + const gateway = createGateway(FILES, () => 0); + expect(errorCode(sent(gateway(mockRequest('/v1/widget/lookup'))))).toBe('auth_required'); + const empty = sent(gateway(mockRequest(LOOKUP, { headers: { 'x-api-key': '' } }))); + expect(errorCode(empty)).toBe('invalid_api_key'); + }); + + it('refuses a wrong origin before it answers a rate-limited key', () => { + const key = { ...FILES.keys.get('nipost_pk_test_mock')!, rateLimited: true }; + const files = { ...FILES, keys: new Map([[key.key, key]]) }; + const headers = { 'x-api-key': key.key, origin: 'http://localhost:3001' }; + const response = sent(createGateway(files, () => 0)(mockRequest(LOOKUP, { headers }))); + expect(errorCode(response)).toBe('origin_not_allowed'); + }); }); diff --git a/packages/mock-server/test/lookup.test.ts b/packages/mock-server/test/lookup.test.ts index ee59c8d..afea4e0 100644 --- a/packages/mock-server/test/lookup.test.ts +++ b/packages/mock-server/test/lookup.test.ts @@ -96,4 +96,33 @@ describe('lookup', () => { const response = lookUp(query); expect([response.status, errorCode(response)]).toEqual([400, 'invalid_request']); }); + + it('refuses a level above the key before it checks credits, and scope before level', () => { + const credits = lookUp('code=FC01Z99ZZ01&level=4', 'nipost_test_mock_no_credits'); + expect([credits.status, errorCode(credits)]).toEqual([403, 'level_not_granted']); + const scope = lookUp('code=FC01Z99ZZ01&level=4', 'nipost_test_mock_no_scope'); + expect([scope.status, errorCode(scope)]).toEqual([403, 'scope_not_granted']); + }); + + it.each(['level=02', 'level=2.0'])('reads %s as level 2, by value', (query) => { + const response = lookUp(`code=FC01Z99ZZ01&${query}`); + expect(response.body).toMatchObject({ data: { recent_house_address: {} } }); + }); + + it.each(['12', '1x', 'x1', '1.5', '-1', '0x2', ''])( + 'answers a level of "%s" with 400', + (level) => { + const response = lookUp(`code=FC01Z99ZZ01&level=${level}`); + expect([response.status, errorCode(response)]).toEqual([400, 'invalid_request']); + }, + ); + + it.each([ + ['a digit in the state', 'F101Z99ZZ01'], + ['a letter in the local government area', 'FCAB' + 'Z99ZZ01'], + ['a digit in the area', 'FC01Z99Z101'], + ['a letter in the unit', 'FC01Z99ZZA1'], + ])('gives invalid for %s', (_name, code) => { + expect(lookUp(`code=${code}`).body).toMatchObject({ data: { status: 'invalid' } }); + }); }); diff --git a/packages/mock-server/test/openapi.test.ts b/packages/mock-server/test/openapi.test.ts index 3086749..8b414ea 100644 --- a/packages/mock-server/test/openapi.test.ts +++ b/packages/mock-server/test/openapi.test.ts @@ -14,6 +14,8 @@ import { FILES, mockRequest, SPEC_DIR, sent } from './mock-request.ts'; interface ResponseObject { readonly $ref?: string; readonly content?: unknown; + readonly headers?: Readonly>; + readonly 'x-gatepost-error-codes'?: Readonly>; } interface Operation { @@ -26,30 +28,67 @@ interface Operation { const openapi = parse(readFileSync(join(SPEC_DIR, 'openapi/gateway.completed.yaml'), 'utf8')) as { paths: Record>; - components: { responses: Record }; + components: { + responses: Record; + headers: Record }>; + }; 'x-gatepost-unknown-path': { responses: Record }; }; const ajv = new Ajv2020({ strict: false }); ajv.addSchema(openapi, 'openapi'); // The response of the operation and the status, or the file's answer to a path that it lacks. -function bodySchema(path: string, method: string, status: number): string { +function declaredResponse(path: string, method: string, status: number): ResponseObject { const responses = openapi.paths[path]?.[method]?.responses ?? openapi['x-gatepost-unknown-path'].responses; const response = responses[String(status)]; const shared = response?.$ref?.split('/').at(-1); - const content = - shared === undefined ? response?.content : openapi.components.responses[shared]?.content; - const media = (content as { 'application/json': { schema: { $ref: string } } })[ + const declared = shared === undefined ? response : openapi.components.responses[shared]; + if (declared === undefined) { + throw new Error(`The OpenAPI file declares no ${String(status)} for ${method} ${path}.`); + } + return declared; +} + +function bodySchema(declared: ResponseObject): string { + const media = (declared.content as { 'application/json': { schema: { $ref: string } } })[ 'application/json' ]; return `openapi${media.schema.$ref}`; } +// Each header that the response declares is there and has a value of the declared type. +function expectDeclaredHeaders( + declared: ResponseObject, + headers: Readonly>, +): void { + for (const [name, reference] of Object.entries(declared.headers ?? {})) { + const value = headers[name]; + if (name !== 'Retry-After') { + expect(value, `${name} is missing`).toBeDefined(); + } + if (value !== undefined) { + const header = openapi.components.headers[reference.$ref.split('/').at(-1) ?? '']; + expect(ajv.validate(header?.schema ?? false, Number(value)), `${name}: ${value}`).toBe(true); + } + } +} + +// An error code is one of the codes that the response lists. +function expectDeclaredCode(declared: ResponseObject, body: unknown): void { + const codes = declared['x-gatepost-error-codes']; + const code = (body as { error?: { code?: string } }).error?.code; + if (codes !== undefined && code !== undefined) { + expect(Object.keys(codes)).toContain(code); + } +} + interface Probe { readonly target: string; readonly method?: string; readonly key?: string; + readonly origin?: string; + readonly anonymous?: boolean; readonly body?: string; } @@ -76,16 +115,37 @@ const PROBES: readonly Probe[] = [ { target: '/v1/assembly/disassemble?code=FC01Z99ZZ01' }, { target: '/v1/assembly/disassemble?code=HELLO' }, { target: '/v1/widget/lookup' }, + { target: '/v1/lookup?code=FC01Z99ZZ01', anonymous: true }, + { target: '/v1/lookup?code=FC01Z99ZZ01', key: 'nipost_pk_test_mock', origin: 'http://x.test' }, + { target: '/v1/search/reverse?lat=9', key: 'nipost_test_mock_l1' }, + { target: '/v1/search/reverse?lat=9&lng=7', key: 'nipost_test_mock_rate_limited' }, + { target: '/v1/search/nearby?lat=9' }, + { target: '/v1/search/autocomplete?q=FC01Z99ZZ011' }, + { target: '/v1/assembly/assemble', method: 'POST', body: '{}' }, ]; describe('the bodies of the mock server', () => { it.each(PROBES)('match the OpenAPI file for $target', (probe) => { const method = probe.method ?? 'GET'; - const headers = { 'x-api-key': probe.key ?? 'nipost_test_mock_l3' }; + const headers: Record = {}; + if (probe.anonymous !== true) { + headers['x-api-key'] = probe.key ?? 'nipost_test_mock_l3'; + } + if (probe.origin !== undefined) { + headers['origin'] = probe.origin; + } const request = mockRequest(probe.target, { method, headers, body: probe.body ?? '' }); const response = sent(createGateway(FILES, () => 0)(request)); - const validate = ajv.getSchema(bodySchema(request.path, method.toLowerCase(), response.status)); + const declared = declaredResponse(request.path, method.toLowerCase(), response.status); + const validate = ajv.getSchema(bodySchema(declared)); expect(validate?.(response.body), JSON.stringify(validate?.errors)).toBe(true); + + expectDeclaredHeaders(declared, response.headers); + // A reply with no key declares no rate-limit header, and the mock server sends none. + if (probe.anonymous === true) { + expect(Object.keys(response.headers)).not.toContain('X-RateLimit-Limit'); + } + expectDeclaredCode(declared, response.body); }); }); diff --git a/packages/mock-server/test/search.test.ts b/packages/mock-server/test/search.test.ts index f23ba96..b74f34c 100644 --- a/packages/mock-server/test/search.test.ts +++ b/packages/mock-server/test/search.test.ts @@ -60,6 +60,34 @@ describe('reverse', () => { }); }); + it.each([ + ['a latitude that is right and a longitude that is not', 'lat=9&lng=8'], + ['a longitude that is right and a latitude that is not', 'lat=8&lng=7'], + ])('finds nothing for %s', (_name, query) => { + expect(data(sent(ask(`/v1/search/reverse?${query}`)))).toMatchObject({ found: false }); + }); + + it.each([ + ['0', false], + ['1', false], + ['4.1', false], + ['4.2', true], + ['5', true], + ])('with a radius of %s m, finds the unit at 4.2 m: %s', (radius, found) => { + const body = data(sent(ask(`/v1/search/reverse?lat=9&lng=7&max_distance_m=${radius}`))); + expect(body).toMatchObject({ found, radius_m: Number(radius) }); + }); + + it('accepts a radius of 0 at a point with nothing near it', () => { + const found = data(sent(ask('/v1/search/reverse?lat=4&lng=3&max_distance_m=0'))); + expect(found).toMatchObject({ found: false, radius_m: 0 }); + }); + + it('reads a number as decimal text only', () => { + expect(sent(ask('/v1/search/reverse?lat=0x9&lng=7')).status).toBe(400); + expect(sent(ask('/v1/search/reverse?lat=9&lng=7e0')).status).toBe(200); + }); + it('lowers a radius above 250 m to 250 m', () => { const found = data(sent(ask('/v1/search/reverse?lat=4&lng=3&max_distance_m=900'))); expect(found).toMatchObject({ radius_m: 250 }); @@ -81,8 +109,13 @@ describe('nearby', () => { expect(sent(ask('/v1/search/nearby?lat=9&lng=7&radius=200')).body).toEqual({ data: [] }); }); - it('answers a point with no longitude with 400', () => { - expect(sent(ask('/v1/search/nearby?lat=9')).status).toBe(400); + it.each([ + ['no longitude', 'lat=9'], + ['no latitude', 'lng=7'], + ['a latitude that is not a number', 'lat=north&lng=7'], + ['a negative radius', 'lat=9&lng=7&radius=-1'], + ])('answers %s with 400', (_name, query) => { + expect(sent(ask(`/v1/search/nearby?${query}`)).status).toBe(400); }); }); @@ -114,3 +147,17 @@ describe('autocomplete', () => { expect(sent(ask(`/v1/search/autocomplete?q=${q}`)).status).toBe(400); }); }); + +describe('autocomplete with a state list out of order', () => { + it('sorts the suggestions and lists each code once', () => { + const files = { ...FILES, states: ['EK', 'ED', 'EK', 'EB'] }; + const request = mockRequest('/v1/search/autocomplete?q=E', { + headers: { 'x-api-key': 'nipost_test_mock_l1' }, + }); + const body = data(sent(createGateway(files, () => 0)(request))); + expect(body).toEqual({ + segment: 'state', + suggestions: [{ code: 'EB' }, { code: 'ED' }, { code: 'EK' }], + }); + }); +}); From bec21e30883ea704f9c5ed4515c6f8f7aeec8a28 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:08:38 +0100 Subject: [PATCH 08/14] fix(mock): stop a pending delay at close and end every error path A delayed response kept a timer alive after the client left or the server closed, so a process with a long delay did not exit for seconds. The delay now ends when the client closes the connection and when the server closes. A second close call now returns the first result, so two signals no longer give an unhandled rejection. A failure inside the error reply now closes the connection instead of leaving a rejection. The URL of an IPv6 host has brackets, and the host :: gives localhost. Signed-off-by: MobileMage --- packages/mock-server/src/scenarios.ts | 3 +- packages/mock-server/src/server.ts | 71 ++++++++++++++++++------ packages/mock-server/test/main.test.ts | 9 +++ packages/mock-server/test/server.test.ts | 17 ++++++ 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/packages/mock-server/src/scenarios.ts b/packages/mock-server/src/scenarios.ts index 485b95d..9ff0d8e 100644 --- a/packages/mock-server/src/scenarios.ts +++ b/packages/mock-server/src/scenarios.ts @@ -8,7 +8,8 @@ function mockError(code: string, message: string): Reply { /** * Plays the responses of the contract scenarios. Each run of a scenario keeps its own place - * in the list of responses, and the last response repeats. + * in the list of responses, and the last response repeats. The places stay in memory until the + * player goes away, so a long-lived server grows by one entry for each run. * * @param scenarios - The responses of each scenario, by its id. * @returns A function that takes the values of `X-Scenario-Id` and `X-Scenario-Run`, and diff --git a/packages/mock-server/src/server.ts b/packages/mock-server/src/server.ts index 05f38d5..8659155 100644 --- a/packages/mock-server/src/server.ts +++ b/packages/mock-server/src/server.ts @@ -62,16 +62,36 @@ async function readRequest(incoming: IncomingMessage): Promise { }; } +// Waits for a delay. It ends early, with false, when the server closes or the client leaves. +async function waited( + ms: number, + outgoing: ServerResponse, + stopping: AbortSignal, +): Promise { + const left = new AbortController(); + outgoing.once('close', () => { + left.abort(); + }); + try { + await sleep(ms, undefined, { signal: AbortSignal.any([stopping, left.signal]) }); + return true; + } catch { + return false; + } +} + async function send( incoming: IncomingMessage, outgoing: ServerResponse, reply: Reply, - extraMs: number, + wait: { readonly extraMs: number; readonly stopping: AbortSignal }, ): Promise { if (reply.kind === 'hang') { return; } - await sleep(reply.delayMs + extraMs); + if (!(await waited(reply.delayMs + wait.extraMs, outgoing, wait.stopping))) { + return; + } if (reply.kind === 'drop') { incoming.socket.destroy(); return; @@ -81,6 +101,14 @@ async function send( outgoing.end(reply.body); } +// The host part of the URL: every address means localhost, and an IPv6 address has brackets. +function urlHost(host: string): string { + if (host === '0.0.0.0' || host === '::') { + return 'localhost'; + } + return host.includes(':') ? `[${host}]` : host; +} + function listen(server: Server, port: number, host: string): Promise { return new Promise((resolve, reject) => { server.once('error', reject); @@ -116,6 +144,7 @@ export async function startMockServer(options: MockServerOptions = {}): Promise< const gateway = createGateway(files, options.now ?? Date.now); const play = createScenarioPlayer(files.scenarios); const extraMs = options.delayMs ?? 0; + const stopping = new AbortController(); const choose = (request: MockRequest): Reply => { const scenario = request.headers['x-scenario-id']; return request.method === 'OPTIONS' || scenario === undefined @@ -124,28 +153,36 @@ export async function startMockServer(options: MockServerOptions = {}): Promise< }; const server = createServer((incoming, outgoing) => { readRequest(incoming) - .then((request) => send(incoming, outgoing, choose(request), extraMs)) + .then((request) => + send(incoming, outgoing, choose(request), { extraMs, stopping: stopping.signal }), + ) .catch((error: unknown) => { // A broken fixture is a bug in the spec. The test that hit it sees the message. const message = error instanceof Error ? error.message : String(error); const reply = jsonReply(500, { error: { code: 'mock_error', message } }); - return send(incoming, outgoing, reply, 0); + return send(incoming, outgoing, reply, { extraMs: 0, stopping: stopping.signal }); + }) + .catch(() => { + // The error reply failed too. Close this connection, so that no rejection stays open. + incoming.socket.destroy(); }); }); const host = options.host ?? '127.0.0.1'; const port = await listen(server, options.port ?? 4010, host); - return { - url: `http://${host === '0.0.0.0' ? 'localhost' : host}:${String(port)}`, - close: () => - new Promise((resolve, reject) => { - server.closeAllConnections(); - server.close((error) => { - if (error === undefined) { - resolve(); - } else { - reject(error); - } - }); - }), + let closing: Promise | undefined; + const close = (): Promise => { + closing ??= new Promise((resolve, reject) => { + stopping.abort(); + server.closeAllConnections(); + server.close((error) => { + if (error === undefined) { + resolve(); + } else { + reject(error); + } + }); + }); + return closing; }; + return { url: `http://${urlHost(host)}:${String(port)}`, close }; } diff --git a/packages/mock-server/test/main.test.ts b/packages/mock-server/test/main.test.ts index a2d5bf0..600ddcb 100644 --- a/packages/mock-server/test/main.test.ts +++ b/packages/mock-server/test/main.test.ts @@ -72,4 +72,13 @@ describe('the mock server command', () => { expect(result.code).toBe(2); expect(result.stderr).toContain('/no/such/spec/fixtures'); }); + + it('exits fast on SIGTERM while a delayed response is pending', async () => { + const started = performance.now(); + const result = await run({ PORT: '0', MOCK_DELAY_MS: '5000' }, async (line) => { + const url = /http:\/\/\S+/.exec(line)?.[0] ?? ''; + await fetch(`${url}/healthz`, { signal: AbortSignal.timeout(200) }).catch(() => undefined); + }); + expect([result.code, performance.now() - started < 2500]).toEqual([0, true]); + }); }); diff --git a/packages/mock-server/test/server.test.ts b/packages/mock-server/test/server.test.ts index 22d4638..69e1582 100644 --- a/packages/mock-server/test/server.test.ts +++ b/packages/mock-server/test/server.test.ts @@ -209,4 +209,21 @@ describe('the mock server', () => { expect(url).toMatch(/^http:\/\/localhost:\d+$/); expect((await fetch(`${url}/healthz`)).status).toBe(200); }); + + it('lets close run twice', async () => { + const server = await startMockServer({ port: 0 }); + await server.close(); + await expect(server.close()).resolves.toBeUndefined(); + }); + + it('gives a URL with brackets for an IPv6 address', async () => { + const { url } = await start({ host: '::1' }); + expect(url).toMatch(/^http:\/\/\[::1\]:\d+$/); + expect((await fetch(`${url}/healthz`)).status).toBe(200); + }); + + it('gives a URL with localhost when it listens on every IPv6 address', async () => { + const { url } = await start({ host: '::' }); + expect(url).toMatch(/^http:\/\/localhost:\d+$/); + }); }); From 135067d78098f648eb02b973c899bcca0a041f3e Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:11:29 +0100 Subject: [PATCH 09/14] build(mock): document the mock server and build its image in CI The PHP client and the docs need the mock server without a TypeScript setup, so it ships as an image that runs the source on Node. CI builds the image and looks up the synthetic unit on each pull request, so a change cannot break the image unseen. A separate workflow publishes it with a provenance attestation, and only a release tag or a person starts it, because a pushed image is public. Signed-off-by: MobileMage --- .dockerignore | 12 ++++ .github/workflows/ci.yml | 26 ++++++++ .github/workflows/mock-release.yml | 73 ++++++++++++++++++++ AGENTS.md | 2 +- README.md | 7 +- packages/mock-server/Dockerfile | 25 +++++++ packages/mock-server/README.md | 84 ++++++++++++++++++++++++ packages/mock-server/test/readme.test.ts | 69 +++++++++++++++++++ 8 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/mock-release.yml create mode 100644 packages/mock-server/Dockerfile create mode 100644 packages/mock-server/README.md create mode 100644 packages/mock-server/test/readme.test.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d294950 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +# The image of the mock server needs these paths only. The rest, such as node_modules, stays out +# of the build context. +* +!LICENSE +!NOTICE +!spec/data +!spec/fixtures +!spec/contract +!packages/mock-server/package.json +!packages/mock-server/src diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4cb220..1cef8aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,3 +99,29 @@ jobs: image=ghcr.io/gitleaks/gitleaks # v8.30.1 digest=sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f docker run --rm -v "$PWD:/repo" "$image@$digest" git /repo + + # The job builds the image of the mock server and calls it once. It never pushes the image. + # mock-release.yml publishes it. + mock-image: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + - run: docker build --file packages/mock-server/Dockerfile --tag postcode-mock:ci . + - name: Start the image and look up the synthetic unit + run: | + # The key comes from the key table that the mock server reads, so no key is in this file. + key="$(jq --raw-output '.keys[] | select(.lookupLevel == 1 and .kind == "secret" + and .lookupScope and .credits and (.rateLimited | not)) | .key' \ + spec/fixtures/keys.json | head --lines=1)" + docker run --detach --name mock --publish 4010:4010 postcode-mock:ci + curl --silent --show-error --fail --retry 10 --retry-connrefused --retry-delay 1 \ + http://localhost:4010/healthz + curl --silent --show-error --fail-with-body --header "X-API-Key: $key" \ + 'http://localhost:4010/v1/lookup?code=FC-01-Z99-ZZ-01' | tee "$RUNNER_TEMP/lookup.json" + grep --quiet '"valid":true' "$RUNNER_TEMP/lookup.json" + - if: always() + run: docker logs mock diff --git a/.github/workflows/mock-release.yml b/.github/workflows/mock-release.yml new file mode 100644 index 0000000..fe24183 --- /dev/null +++ b/.github/workflows/mock-release.yml @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +name: mock release + +# Publishes the image of the mock server to the GitHub container registry, with a provenance +# attestation (SEC-7). A pushed image is public, so the workflow starts only for a tag such as +# postcode-mock-v0.1.0, or when a maintainer starts it by hand. No other event starts it. +on: + push: + tags: ['postcode-mock-v*'] + workflow_dispatch: + inputs: + version: + description: The version of the image, such as 0.1.0. It must match package.json. + required: true + type: string + +permissions: + contents: read + +concurrency: + group: mock-release + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + packages: write + id-token: write + attestations: write + env: + IMAGE: ghcr.io/gatepost-dev/postcode-mock + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + # The tag or the input gives the version. It must match package.json, so that the image + # and the source in the repo agree. + - name: Check the version + id: version + env: + INPUT_VERSION: ${{ inputs.version }} + REF_NAME: ${{ github.ref_name }} + run: | + version="${INPUT_VERSION:-${REF_NAME#postcode-mock-v}}" + declared="$(jq --raw-output .version packages/mock-server/package.json)" + if [ "$version" != "$declared" ]; then + echo "The version is $version, but package.json says $declared." >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: push + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: packages/mock-server/Dockerfile + push: true + tags: ${{ env.IMAGE }}:${{ steps.version.outputs.version }} + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/AGENTS.md b/AGENTS.md index c9137c9..aacaca3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the TypeScript packages. Today it has `@gatepost/core`. +This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the TypeScript packages. Today it has `@gatepost/core` and the private `@gatepost/mock-server`. ## Read first diff --git a/README.md b/README.md index 56f346c..5ea3582 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ TypeScript packages for Nigeria's National Digital Postcode. > Unofficial. Not made or endorsed by NIPOST. -| Package | What it does | -| --------------------------------- | ------------------------------------------ | -| [`@gatepost/core`](packages/core) | Parse, check and format postcodes offline. | +| Package | What it does | +| ----------------------------------------------- | --------------------------------------------------- | +| [`@gatepost/core`](packages/core) | Parse, check and format postcodes offline. | +| [`@gatepost/mock-server`](packages/mock-server) | Mock NIPOST's gateway for client tests. Not on npm. | ## Develop diff --git a/packages/mock-server/Dockerfile b/packages/mock-server/Dockerfile new file mode 100644 index 0000000..5eb7719 --- /dev/null +++ b/packages/mock-server/Dockerfile @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +# Build from the root of the js repo, with the spec submodule checked out: +# docker build --file packages/mock-server/Dockerfile --tag postcode-mock . +# The mock server has no dependencies and no build. Node runs its TypeScript source. +FROM node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 + +ARG DESCRIPTION="Unofficial. Not made or endorsed by NIPOST. A mock of NIPOST's gateway." +LABEL org.opencontainers.image.title="postcode-mock" \ + org.opencontainers.image.description="$DESCRIPTION" \ + org.opencontainers.image.source="https://github.com/gatepost-dev/js" \ + org.opencontainers.image.licenses="Apache-2.0" + +WORKDIR /app +COPY LICENSE NOTICE ./ +COPY spec/data spec/data +COPY spec/fixtures spec/fixtures +COPY spec/contract spec/contract +COPY packages/mock-server/package.json packages/mock-server/package.json +COPY packages/mock-server/src packages/mock-server/src + +ENV HOST=0.0.0.0 PORT=4010 +USER node +EXPOSE 4010 +CMD ["node", "packages/mock-server/src/main.ts"] diff --git a/packages/mock-server/README.md b/packages/mock-server/README.md new file mode 100644 index 0000000..cad84bb --- /dev/null +++ b/packages/mock-server/README.md @@ -0,0 +1,84 @@ + + + gatepost + + +# @gatepost/mock-server + +A local mock of NIPOST's postcode gateway, for the tests of Gatepost's clients. + +> Unofficial. Not made or endorsed by NIPOST. + +[![CI](https://github.com/gatepost-dev/js/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gatepost-dev/js/actions/workflows/ci.yml) +[![Licence](https://img.shields.io/badge/licence-Apache--2.0-blue)](https://github.com/gatepost-dev/js/blob/main/LICENSE) + +## Install + +The package does not go to npm. Run it from a clone of this repo. It needs no install step, because it has no dependencies and Node runs its TypeScript source. + +```sh +git clone --recurse-submodules https://github.com/gatepost-dev/js +node js/packages/mock-server/src/main.ts +``` + +The command listens on port 4010 and prints its address. `GET /healthz` answers 200 with no key. + +A release tag starts a workflow that publishes the image `ghcr.io/gatepost-dev/postcode-mock`. The image listens on port 4010. Until the first release, build the image from the root of a clone: + +```sh +docker build --file packages/mock-server/Dockerfile --tag postcode-mock . +docker run --publish 4010:4010 postcode-mock +``` + +## Quickstart + +```ts +import { startMockServer } from '@gatepost/mock-server'; + +const mock = await startMockServer({ port: 0 }); +const response = await fetch(`${mock.url}/v1/lookup?code=FC-01-Z99-ZZ-01`, { + headers: { 'X-API-Key': 'nipost_test_mock_l1' }, +}); +const body = await response.json(); +await mock.close(); +``` + +`body` is `{ data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid', verified: false } }`, the gateway's answer at lookup level 1. + +## What it does + +- It answers the six gateway endpoints with the synthetic responses in `spec/fixtures`. `spec/fixtures/README.md` says which request gets which response. +- It treats each key in `spec/fixtures/keys.json` in its own way: a lookup level from 1 to 3, no credits, no lookup scope, a rate limit, or a publishable key for the origin `http://localhost:3000`. A request with no key gets 401 `auth_required`, and any other key gets 401 `invalid_api_key`. +- It counts the requests of each key in each clock minute, and sends the `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers, as the gateway does. +- It does not answer an empty `q` of `autocomplete`, because the gateway does not answer one. The connection stays open until the client gives up. +- A request with the headers `X-Scenario-Id` and `X-Scenario-Run` gets the next response of that contract scenario in `spec/contract`. The CORS headers allow both, so browser tests can send them. The gateway itself does not allow them. + +| Variable | Meaning | Default | +| ------------------- | ----------------------------------------------- | ----------------------------------- | +| `PORT` | the port. 0 picks a free one | 4010 | +| `HOST` | the address to listen on | 127.0.0.1, and 0.0.0.0 in the image | +| `MOCK_DELAY_MS` | a wait before every response, for timeout tests | 0 | +| `GATEPOST_SPEC_DIR` | the spec folder | the `spec` submodule | + +## Requirements + +| Requirement | Version | +| ------------- | ------------------------------------------ | +| Node | 22.22.2 or later 22.x, or 24.15.0 or later | +| Gatepost spec | 0.2.0 | + +## Docs + +`spec/client.md` defines the behaviour that the contract scenarios test, and `spec/contract/README.md` defines their format. + +## Support + +Report bugs in GitHub Issues. Report security problems privately, as `SECURITY.md` describes. + +## Contributing + +Read [`CONTRIBUTING.md`](https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md) before you open a pull request. + +## Licence + +Apache-2.0. See `LICENSE` and `NOTICE`. diff --git a/packages/mock-server/test/readme.test.ts b/packages/mock-server/test/readme.test.ts new file mode 100644 index 0000000..9824f71 --- /dev/null +++ b/packages/mock-server/test/readme.test.ts @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; +import { SPEC_DIR } from './mock-request.ts'; + +const PACKAGE = fileURLToPath(new URL('../', import.meta.url)); +const README = readFileSync(join(PACKAGE, 'README.md'), 'utf8'); +const TEMP = join(PACKAGE, 'temp'); + +afterAll(() => { + rmSync(TEMP, { recursive: true, force: true }); +}); + +describe('README', () => { + // DOC-3: the Quickstart runs, and gives the body that the next sentence names. + it('runs the Quickstart, which gets the lookup body that it names', async () => { + const [, block = ''] = /```ts\n([\s\S]*?)```/.exec(README) ?? []; + const source = block.replace("'@gatepost/mock-server'", "'../src/index.ts'"); + mkdirSync(TEMP, { recursive: true }); + writeFileSync(join(TEMP, 'quickstart.ts'), `${source}\nexport { body };\n`); + const { body } = (await import(join(TEMP, 'quickstart.ts'))) as { body: unknown }; + expect(body).toEqual({ + data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid', verified: false }, + }); + expect(README).toContain("postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid'"); + }); + + it('names the version of the spec in the submodule', () => { + const version = readFileSync(join(SPEC_DIR, 'VERSION'), 'utf8').trim(); + const pattern = `Gatepost spec\\s*\\|\\s*${version.replaceAll('.', '\\.')}\\s*\\|`; + expect(README).toMatch(new RegExp(pattern)); + }); + + it('uses a key that keys.json holds, and names the six paths of the gateway', () => { + const keys = JSON.parse(readFileSync(join(SPEC_DIR, 'fixtures', 'keys.json'), 'utf8')) as { + keys: { key: string }[]; + }; + expect(keys.keys.map((entry) => entry.key)).toContain('nipost_test_mock_l1'); + expect(README).toContain('nipost_test_mock_l1'); + expect(README).toContain('/healthz'); + }); +}); + +describe('image recipe', () => { + const ROOT = join(PACKAGE, '..', '..'); + const dockerfile = readFileSync(join(PACKAGE, 'Dockerfile'), 'utf8'); + + it('copies every spec folder that the server reads, and nothing else of the spec', () => { + const folders = [...dockerfile.matchAll(/^COPY (spec\/\w+) /gm)].map((match) => match[1] ?? ''); + expect(folders).toEqual(['spec/data', 'spec/fixtures', 'spec/contract']); + const ignore = readFileSync(join(ROOT, '.dockerignore'), 'utf8'); + for (const folder of folders) { + expect(ignore).toContain(`!${folder}\n`); + } + }); + + it('listens on the port that the command and the README name, on every address', () => { + expect(dockerfile).toContain('HOST=0.0.0.0 PORT=4010'); + expect(dockerfile).toContain('EXPOSE 4010'); + expect(README).toContain('4010'); + }); + + it('runs as the node user with no root rights', () => { + expect(dockerfile).toMatch(/^USER node$/m); + }); +}); From 197161eefc06ad22c3bf074311ba5a538a23d380 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:34:01 +0100 Subject: [PATCH 10/14] fix(repo): retry a reset connection when CI probes the mock image Docker accepts the first connection on the published port before Node listens, and then resets it. The option --retry-connrefused does not retry a reset, so the probe failed with exit code 56. The option --retry-all-errors retries it. Signed-off-by: MobileMage --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cef8aa..9917feb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,7 +118,7 @@ jobs: and .lookupScope and .credits and (.rateLimited | not)) | .key' \ spec/fixtures/keys.json | head --lines=1)" docker run --detach --name mock --publish 4010:4010 postcode-mock:ci - curl --silent --show-error --fail --retry 10 --retry-connrefused --retry-delay 1 \ + curl --silent --show-error --fail --retry 10 --retry-all-errors --retry-delay 1 \ http://localhost:4010/healthz curl --silent --show-error --fail-with-body --header "X-API-Key: $key" \ 'http://localhost:4010/v1/lookup?code=FC-01-Z99-ZZ-01' | tee "$RUNNER_TEMP/lookup.json" From 6e6ba65ae14d4bdca10e4f7e339e2974a47cc6a9 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:34:02 +0100 Subject: [PATCH 11/14] fix(mock): bind the loopback address when HOST is empty An empty HOST gave an empty string, and Node then bound every interface. The command now treats an empty HOST as unset, as it does for PORT. Two tests pin the loopback default, and the README says that the server trusts a checked spec folder. Signed-off-by: MobileMage --- packages/mock-server/README.md | 4 ++++ packages/mock-server/src/main.ts | 8 +++++++- packages/mock-server/test/main.test.ts | 5 +++++ packages/mock-server/test/server.test.ts | 5 +++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/mock-server/README.md b/packages/mock-server/README.md index cad84bb..3084524 100644 --- a/packages/mock-server/README.md +++ b/packages/mock-server/README.md @@ -60,6 +60,10 @@ await mock.close(); | `MOCK_DELAY_MS` | a wait before every response, for timeout tests | 0 | | `GATEPOST_SPEC_DIR` | the spec folder | the `spec` submodule | +An empty value counts as no value. At start, the server checks the shape of each file in the spec +folder and names a broken file. It does not check a fixture body against the OpenAPI file. Point +`GATEPOST_SPEC_DIR` only at a spec folder that `make check` has passed. + ## Requirements | Requirement | Version | diff --git a/packages/mock-server/src/main.ts b/packages/mock-server/src/main.ts index 53f3630..e1a2169 100644 --- a/packages/mock-server/src/main.ts +++ b/packages/mock-server/src/main.ts @@ -15,11 +15,17 @@ function wholeNumber(name: string, fallback: number): number { return parsed; } +// An empty HOST counts as unset, as an empty PORT does. It must not bind every interface. +function hostFromEnvironment(): string { + const host = process.env['HOST'] ?? ''; + return host === '' ? '127.0.0.1' : host; +} + function optionsFromEnvironment(): MockServerOptions { const specDir = process.env['GATEPOST_SPEC_DIR'] ?? ''; return { port: wholeNumber('PORT', 4010), - host: process.env['HOST'] ?? '127.0.0.1', + host: hostFromEnvironment(), delayMs: wholeNumber('MOCK_DELAY_MS', 0), ...(specDir === '' ? {} : { specDir }), }; diff --git a/packages/mock-server/test/main.test.ts b/packages/mock-server/test/main.test.ts index 600ddcb..81fef3f 100644 --- a/packages/mock-server/test/main.test.ts +++ b/packages/mock-server/test/main.test.ts @@ -58,6 +58,11 @@ describe('the mock server command', () => { expect([health, result.code, result.stderr]).toEqual([200, 0, '']); }); + it('binds the loopback address when HOST is empty', async () => { + const result = await run({ PORT: '0', HOST: '' }); + expect(result.stdout).toMatch(/listens on http:\/\/127\.0\.0\.1:\d+\n$/); + }); + it.each([ ['PORT', 'abc'], ['MOCK_DELAY_MS', '-5'], diff --git a/packages/mock-server/test/server.test.ts b/packages/mock-server/test/server.test.ts index 69e1582..90c23d2 100644 --- a/packages/mock-server/test/server.test.ts +++ b/packages/mock-server/test/server.test.ts @@ -118,6 +118,11 @@ describe('the contract scenarios over HTTP', () => { }); describe('the mock server', () => { + it('listens on the loopback address when no host is given', async () => { + const server = await start(); + expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + }); + it('answers with the gateway headers and a body that ends with a line feed', async () => { const { url } = await start(); const response = await fetch(`${url}/v1/lookup?code=FC-01-Z99-ZZ-01`, { headers: KEY }); From 2ee026be7be1272f8d58a3d649722000e6335fdd Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:37:06 +0100 Subject: [PATCH 12/14] build(deps): move the spec submodule to the final review fixes The new spec commit makes the deadline scenario less sensitive to a slow runner, checks the postcode of each suggestion, and adds evidence marks and notes to the contract. Signed-off-by: MobileMage --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index 16b5a5b..a4f601d 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 16b5a5ba38fe18c0bc447f299aff37d5b7a70df6 +Subproject commit a4f601d0c58240318a4cce68302574c8bb021cae From 03f268f45895c06c153f3de75b382a417f33eca2 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 11:57:52 +0100 Subject: [PATCH 13/14] chore(repo): point the spec submodule at spec main The spec branch is merged. Its squash commit on main, tagged v0.2.0, holds the same files as the commit that the submodule named before. Signed-off-by: MobileMage --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index a4f601d..359db01 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit a4f601d0c58240318a4cce68302574c8bb021cae +Subproject commit 359db01aa97f2d5b183db431d14dbccb793af9f3 From 0caf3e6ec01d111d3cb7da5d097abebe37b74907 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 11:59:42 +0100 Subject: [PATCH 14/14] test(repo): ignore the made-up key of the key secrecy test CI scans every branch of the repo, and the client branch holds a made-up key in a test that checks that no error message holds the real one. The same ignore entry that the client branch carries lets this branch pass the scan too. Signed-off-by: MobileMage --- .gitleaksignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .gitleaksignore diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..45870a7 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +# This finding is a made-up test sentinel. No gateway issues this key. The test that held it +# in one line now builds it at run time. History cannot change, so the fingerprint stays here. +da87220e01e7b9c94993fc64cfe2e2c7bc2684ef:packages/client/test/key-secrecy.test.ts:generic-api-key:13