From 51d879a734806bda241568c69a9c8c58a06c1a26 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 08:30:33 +0200 Subject: [PATCH] test(stalker): add replay scenario core --- apps/stalker-mock-server/jest.config.ts | 17 + apps/stalker-mock-server/project.json | 7 + apps/stalker-mock-server/src/app.spec.ts | 132 ++ apps/stalker-mock-server/src/app.ts | 77 + .../src/app/replay/replay-request-matcher.ts | 238 +++ .../src/app/replay/replay-response.ts | 48 + .../src/app/replay/replay-run.spec.ts | 608 +++++++ .../src/app/replay/replay-run.ts | 389 +++++ .../src/app/replay/replay-schema.spec.ts | 474 ++++++ .../src/app/replay/replay-schema.ts | 1471 +++++++++++++++++ .../src/app/replay/replay-symbols.ts | 150 ++ .../src/app/replay/replay.constants.ts | 38 + .../src/app/replay/replay.types.ts | 192 +++ apps/stalker-mock-server/src/main.ts | 93 +- .../src/test-stubs/faker.ts | 36 + apps/stalker-mock-server/tsconfig.spec.json | 11 + tools/coverage/coverage-policy.json | 11 +- 17 files changed, 3905 insertions(+), 87 deletions(-) create mode 100644 apps/stalker-mock-server/jest.config.ts create mode 100644 apps/stalker-mock-server/src/app.spec.ts create mode 100644 apps/stalker-mock-server/src/app.ts create mode 100644 apps/stalker-mock-server/src/app/replay/replay-request-matcher.ts create mode 100644 apps/stalker-mock-server/src/app/replay/replay-response.ts create mode 100644 apps/stalker-mock-server/src/app/replay/replay-run.spec.ts create mode 100644 apps/stalker-mock-server/src/app/replay/replay-run.ts create mode 100644 apps/stalker-mock-server/src/app/replay/replay-schema.spec.ts create mode 100644 apps/stalker-mock-server/src/app/replay/replay-schema.ts create mode 100644 apps/stalker-mock-server/src/app/replay/replay-symbols.ts create mode 100644 apps/stalker-mock-server/src/app/replay/replay.constants.ts create mode 100644 apps/stalker-mock-server/src/app/replay/replay.types.ts create mode 100644 apps/stalker-mock-server/src/test-stubs/faker.ts create mode 100644 apps/stalker-mock-server/tsconfig.spec.json diff --git a/apps/stalker-mock-server/jest.config.ts b/apps/stalker-mock-server/jest.config.ts new file mode 100644 index 000000000..fd668350c --- /dev/null +++ b/apps/stalker-mock-server/jest.config.ts @@ -0,0 +1,17 @@ +export default { + displayName: 'stalker-mock-server', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + '^@faker-js/faker$': '/src/test-stubs/faker.ts', + }, + moduleFileExtensions: ['ts', 'js'], + coverageDirectory: '../../coverage/apps/stalker-mock-server', +}; diff --git a/apps/stalker-mock-server/project.json b/apps/stalker-mock-server/project.json index d826d0656..5faa86c83 100644 --- a/apps/stalker-mock-server/project.json +++ b/apps/stalker-mock-server/project.json @@ -33,6 +33,13 @@ }, "lint": { "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/stalker-mock-server/jest.config.ts" + } } } } diff --git a/apps/stalker-mock-server/src/app.spec.ts b/apps/stalker-mock-server/src/app.spec.ts new file mode 100644 index 000000000..c7d2e60fa --- /dev/null +++ b/apps/stalker-mock-server/src/app.spec.ts @@ -0,0 +1,132 @@ +import http from 'node:http'; +import { AddressInfo } from 'node:net'; +import { createStalkerMockApp } from './app.js'; + +interface HttpResult { + status: number; + body: unknown; +} + +async function request( + server: http.Server, + path: string, + options: { method?: string; headers?: Record } = {} +): Promise { + const address = server.address() as AddressInfo; + + return new Promise((resolve, reject) => { + const req = http.request( + { + host: '127.0.0.1', + port: address.port, + path, + method: options.method ?? 'GET', + headers: options.headers, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + resolve({ + status: res.statusCode ?? 0, + body: raw.length > 0 ? JSON.parse(raw) : undefined, + }); + }); + } + ); + req.on('error', reject); + req.end(); + }); +} + +describe('createStalkerMockApp', () => { + let server: http.Server; + let consoleLog: jest.SpyInstance; + + beforeEach(async () => { + consoleLog = jest.spyOn(console, 'log').mockImplementation(); + server = http.createServer( + createStalkerMockApp({ + now: () => new Date('2026-07-27T10:00:00.000Z'), + log: () => undefined, + }) + ); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + }); + + afterEach(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); + consoleLog.mockRestore(); + }); + + it('serves the existing direct portal catalog endpoint', async () => { + const result = await request( + server, + '/portal.php?type=stb&action=handshake', + { headers: { cookie: 'mac=00:1a:79:00:00:01' } } + ); + + expect(result.status).toBe(200); + expect(result.body).toEqual({ + js: expect.objectContaining({ token: expect.any(String) }), + }); + }); + + it('preserves MAC-selected catalog scenarios', async () => { + const result = await request( + server, + '/portal.php?type=itv&action=get_categories', + { headers: { cookie: 'mac=00:1a:79:00:00:03' } } + ); + + expect(result.status).toBe(200); + expect(result.body).toEqual({ + js: [ + expect.objectContaining({ id: '1001' }), + expect.objectContaining({ id: '1002' }), + expect.objectContaining({ + id: '1099', + censored: '1', + }), + ], + }); + }); + + it('serves the existing PWA proxy endpoint', async () => { + const result = await request( + server, + '/stalker?url=http%3A%2F%2Fportal.invalid&type=stb&action=handshake&macAddress=00%3A1a%3A79%3A00%3A00%3A01' + ); + + expect(result.status).toBe(200); + expect(result.body).toEqual({ + payload: { + js: expect.objectContaining({ token: expect.any(String) }), + }, + }); + }); + + it('keeps health and reset lifecycle routes available', async () => { + await expect(request(server, '/health')).resolves.toEqual({ + status: 200, + body: { + status: 'ok', + timestamp: '2026-07-27T10:00:00.000Z', + }, + }); + await expect( + request(server, '/reset', { method: 'POST' }) + ).resolves.toEqual({ + status: 200, + body: { + status: 'reset', + timestamp: '2026-07-27T10:00:00.000Z', + }, + }); + }); +}); diff --git a/apps/stalker-mock-server/src/app.ts b/apps/stalker-mock-server/src/app.ts new file mode 100644 index 000000000..7138e7fbb --- /dev/null +++ b/apps/stalker-mock-server/src/app.ts @@ -0,0 +1,77 @@ +import cors from 'cors'; +import express, { Express, Request, Response } from 'express'; +import { resetAll } from './app/data-store.js'; +import dispatchPortalAction from './app/routes/dispatch.js'; +import portalRouter from './app/routes/portal.route.js'; + +export interface StalkerMockAppOptions { + now?: () => Date; + log?: (message: string) => void; +} + +export function createStalkerMockApp( + options: StalkerMockAppOptions = {} +): Express { + const now = options.now ?? (() => new Date()); + const log = options.log ?? console.log; + const app = express(); + + app.use(cors()); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + app.use((req, _res, next) => { + const action = req.query['action'] ?? '-'; + const mac = + (req.headers['cookie'] ?? '') + .split(';') + .find((cookie) => cookie.trim().startsWith('mac=')) + ?.split('=')[1] + ?.trim() ?? + (req.query['macAddress'] as string) ?? + 'no-mac'; + log( + `[${now().toISOString()}] ${req.method} ${req.path} action=${action} mac=${mac}` + ); + next(); + }); + + app.use('/portal.php', portalRouter); + + app.get('/stalker', (req: Request, res: Response) => { + const query = req.query as Record; + const macAddress = query['macAddress']; + const rest = Object.fromEntries( + Object.entries(query).filter( + ([key]) => key !== 'macAddress' && key !== 'url' + ) + ); + const mac = macAddress ?? '00:1a:79:00:00:01'; + const syntheticReq = { + query: rest, + headers: { cookie: `mac=${mac}` }, + params: {}, + } as unknown as Request; + + let captured: unknown; + const syntheticRes = { + json: (data: unknown) => { + captured = data; + }, + } as unknown as Response; + + dispatchPortalAction(syntheticReq, syntheticRes); + res.json({ payload: captured }); + }); + + app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok', timestamp: now().toISOString() }); + }); + + app.post('/reset', (_req: Request, res: Response) => { + resetAll(); + res.json({ status: 'reset', timestamp: now().toISOString() }); + }); + + return app; +} diff --git a/apps/stalker-mock-server/src/app/replay/replay-request-matcher.ts b/apps/stalker-mock-server/src/app/replay/replay-request-matcher.ts new file mode 100644 index 000000000..a69d83b4e --- /dev/null +++ b/apps/stalker-mock-server/src/app/replay/replay-request-matcher.ts @@ -0,0 +1,238 @@ +import { isDeepStrictEqual } from 'node:util'; +import { ReplaySymbolTable } from './replay-symbols.js'; +import { + ReplayCookieAttributeMatchers, + ReplayExpectation, + ReplayFieldMatchers, + ReplayObservedCookie, + ReplayObservedRequest, + ReplayTemplateString, + ReplayTemplateValue, +} from './replay.types.js'; + +export type ReplayMismatchCode = + | 'origin-mismatch' + | 'method-mismatch' + | 'path-mismatch' + | 'query-presence-mismatch' + | 'query-value-mismatch' + | 'header-presence-mismatch' + | 'header-value-mismatch' + | 'cookie-presence-mismatch' + | 'cookie-value-mismatch' + | 'cookie-attribute-mismatch' + | 'body-kind-mismatch' + | 'body-presence-mismatch' + | 'body-value-mismatch'; + +export type ReplayRequestMatchResult = + | { matched: true } + | { matched: false; code: ReplayMismatchCode }; + +function hasOwn(value: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function resolveExpected( + value: ReplayTemplateString | ReplayTemplateString[], + symbols: ReplaySymbolTable +): string[] { + return (Array.isArray(value) ? value : [value]).map((item) => + symbols.resolveString(item) + ); +} + +function matchStringFields( + matcher: ReplayFieldMatchers, + observed: Record, + symbols: ReplaySymbolTable, + presenceCode: 'query-presence-mismatch' | 'header-presence-mismatch', + valueCode: 'query-value-mismatch' | 'header-value-mismatch' +): ReplayRequestMatchResult { + for (const key of matcher.present) { + if (!hasOwn(observed, key)) { + return { matched: false, code: presenceCode }; + } + } + for (const key of matcher.absent) { + if (hasOwn(observed, key)) { + return { matched: false, code: presenceCode }; + } + } + for (const [key, expected] of Object.entries(matcher.exact)) { + if ( + !hasOwn(observed, key) || + !isDeepStrictEqual( + observed[key], + resolveExpected(expected, symbols) + ) + ) { + return { matched: false, code: valueCode }; + } + } + return { matched: true }; +} + +function matchObjectFields( + matcher: ReplayFieldMatchers, + observed: Record, + symbols: ReplaySymbolTable +): ReplayRequestMatchResult { + for (const key of matcher.present) { + if (!hasOwn(observed, key)) { + return { matched: false, code: 'body-presence-mismatch' }; + } + } + for (const key of matcher.absent) { + if (hasOwn(observed, key)) { + return { matched: false, code: 'body-presence-mismatch' }; + } + } + for (const [key, expected] of Object.entries(matcher.exact)) { + if ( + !hasOwn(observed, key) || + !isDeepStrictEqual(observed[key], symbols.resolveValue(expected)) + ) { + return { matched: false, code: 'body-value-mismatch' }; + } + } + return { matched: true }; +} + +function matchCookieAttributes( + expected: ReplayCookieAttributeMatchers, + observed: ReplayObservedCookie | undefined, + symbols: ReplaySymbolTable +): boolean { + if (observed === undefined) { + return false; + } + const actual = observed.attributes ?? {}; + return Object.entries(expected).every(([attribute, expectedValue]) => { + const resolved = + typeof expectedValue === 'object' || + typeof expectedValue === 'string' + ? symbols.resolveString(expectedValue as ReplayTemplateString) + : expectedValue; + return isDeepStrictEqual( + actual[attribute as keyof typeof actual], + resolved + ); + }); +} + +function matchCookies( + expectation: ReplayExpectation, + observed: ReplayObservedRequest, + symbols: ReplaySymbolTable +): ReplayRequestMatchResult { + const matcher = expectation.request.cookies; + for (const name of matcher.present) { + if (!hasOwn(observed.cookies, name)) { + return { matched: false, code: 'cookie-presence-mismatch' }; + } + } + for (const name of matcher.absent) { + if (hasOwn(observed.cookies, name)) { + return { matched: false, code: 'cookie-presence-mismatch' }; + } + } + for (const [name, expected] of Object.entries(matcher.exact)) { + const observedCookie = observed.cookies[name]; + if ( + !hasOwn(observed.cookies, name) || + observedCookie === undefined || + observedCookie.value !== symbols.resolveString(expected) + ) { + return { matched: false, code: 'cookie-value-mismatch' }; + } + } + for (const [name, attributes] of Object.entries(matcher.attributes)) { + if ( + !matchCookieAttributes(attributes, observed.cookies[name], symbols) + ) { + return { matched: false, code: 'cookie-attribute-mismatch' }; + } + } + return { matched: true }; +} + +function matchBody( + expectation: ReplayExpectation, + observed: ReplayObservedRequest, + symbols: ReplaySymbolTable +): ReplayRequestMatchResult { + const matcher = expectation.request.body; + if (matcher.kind !== observed.body.kind) { + return { matched: false, code: 'body-kind-mismatch' }; + } + switch (matcher.kind) { + case 'absent': + return { matched: true }; + case 'text': + return observed.body.kind === 'text' && + observed.body.value === symbols.resolveString(matcher.exact) + ? { matched: true } + : { matched: false, code: 'body-value-mismatch' }; + case 'form': + return observed.body.kind === 'form' + ? matchObjectFields( + matcher as ReplayFieldMatchers, + observed.body.value, + symbols + ) + : { matched: false, code: 'body-kind-mismatch' }; + case 'json': + if ( + observed.body.kind !== 'json' || + observed.body.value === null || + Array.isArray(observed.body.value) || + typeof observed.body.value !== 'object' + ) { + return { matched: false, code: 'body-kind-mismatch' }; + } + return matchObjectFields(matcher, observed.body.value, symbols); + } +} + +export function matchReplayRequest( + expectation: ReplayExpectation, + observed: ReplayObservedRequest, + symbols: ReplaySymbolTable +): ReplayRequestMatchResult { + if (expectation.origin !== observed.origin) { + return { matched: false, code: 'origin-mismatch' }; + } + if (expectation.method !== observed.method) { + return { matched: false, code: 'method-mismatch' }; + } + if (expectation.path !== observed.path) { + return { matched: false, code: 'path-mismatch' }; + } + + const query = matchStringFields( + expectation.request.query, + observed.query, + symbols, + 'query-presence-mismatch', + 'query-value-mismatch' + ); + if (!query.matched) { + return query; + } + const headers = matchStringFields( + expectation.request.headers, + observed.headers, + symbols, + 'header-presence-mismatch', + 'header-value-mismatch' + ); + if (!headers.matched) { + return headers; + } + const cookies = matchCookies(expectation, observed, symbols); + if (!cookies.matched) { + return cookies; + } + return matchBody(expectation, observed, symbols); +} diff --git a/apps/stalker-mock-server/src/app/replay/replay-response.ts b/apps/stalker-mock-server/src/app/replay/replay-response.ts new file mode 100644 index 000000000..4033b1449 --- /dev/null +++ b/apps/stalker-mock-server/src/app/replay/replay-response.ts @@ -0,0 +1,48 @@ +import { ReplaySymbolTable } from './replay-symbols.js'; +import { + ReplayRenderedResponse, + ReplayResponseDefinition, +} from './replay.types.js'; + +export function renderReplayResponse( + response: ReplayResponseDefinition, + symbols: ReplaySymbolTable +): ReplayRenderedResponse { + const headers = Object.fromEntries( + Object.entries(response.headers).map(([name, values]) => [ + name, + values.map((value) => symbols.resolveString(value)), + ]) + ); + + let body: Buffer; + switch (response.body.kind) { + case 'empty': + body = Buffer.alloc(0); + break; + case 'json': + body = Buffer.from( + JSON.stringify(symbols.resolveValue(response.body.value)) + ); + break; + case 'jsonp': + body = Buffer.from( + `${response.body.callback}(${JSON.stringify( + symbols.resolveValue(response.body.value) + )});` + ); + break; + case 'text': + body = Buffer.from(symbols.resolveString(response.body.value)); + break; + case 'generated': + body = Buffer.alloc(response.body.byteLength, response.body.byte); + break; + } + + return { + status: response.status, + headers, + body, + }; +} diff --git a/apps/stalker-mock-server/src/app/replay/replay-run.spec.ts b/apps/stalker-mock-server/src/app/replay/replay-run.spec.ts new file mode 100644 index 000000000..6bc67b696 --- /dev/null +++ b/apps/stalker-mock-server/src/app/replay/replay-run.spec.ts @@ -0,0 +1,608 @@ +/* eslint-disable max-lines, @typescript-eslint/no-non-null-assertion -- Replay lifecycle contracts share one typed scenario harness whose asserted values are created in the same test. */ +import { + REPLAY_MAX_REQUESTS, + REPLAY_RUN_HARD_LIFETIME_MS, + REPLAY_RUN_INACTIVITY_MS, +} from './replay.constants.js'; +import { + createReplayRun, + ReplayFinalizeResult, + ReplayRunError, +} from './replay-run.js'; +import { + ReplayExpectation, + ReplayFixtureV1, + ReplayObservedRequest, + ReplayRequestBodyMatcher, + ReplayResponseBody, +} from './replay.types.js'; + +function requestBodyMatcher(): ReplayRequestBodyMatcher { + return { kind: 'absent' }; +} + +function replayExpectation( + id: string, + options: { + requestBody?: ReplayRequestBodyMatcher; + responseBody?: ReplayResponseBody; + min?: number; + max?: number; + } = {} +): ReplayExpectation { + return { + id, + operation: id, + origin: 'portal', + method: 'GET', + path: '/portal.php', + request: { + query: { + exact: { action: id }, + present: [], + absent: [], + }, + headers: { + exact: {}, + present: [], + absent: [], + }, + cookies: { + exact: {}, + present: [], + absent: [], + attributes: {}, + }, + body: options.requestBody ?? requestBodyMatcher(), + }, + response: { + status: 200, + headers: { + 'content-type': ['application/json'], + }, + body: options.responseBody ?? { + kind: 'json', + value: { js: true }, + }, + }, + cardinality: { + min: options.min ?? 1, + max: options.max ?? options.min ?? 1, + }, + }; +} + +function replayFixture( + expectations: ReplayExpectation[], + options: { + barrier?: { name: string; releaseWhenMatched: number }; + mode?: 'ordered' | 'unordered'; + } = {} +): ReplayFixtureV1 { + return { + schemaVersion: 1, + scenarioId: 'run-contract', + origins: { portal: {} }, + entry: { origin: 'portal', path: '/c/' }, + expectedEndpoint: { origin: 'portal', path: '/portal.php' }, + initialState: 'start', + terminalState: 'complete', + failOnUnexpectedRequest: true, + symbols: [ + { kind: 'generate', symbol: 'mac', valueKind: 'mac' }, + { + kind: 'generate', + symbol: 'username', + valueKind: 'credential', + }, + { + kind: 'generate', + symbol: 'password', + valueKind: 'credential', + }, + { kind: 'generate', symbol: 'token', valueKind: 'token' }, + { kind: 'generate', symbol: 'random', valueKind: 'random' }, + { kind: 'generate', symbol: 'cookie', valueKind: 'cookie' }, + { + kind: 'generate', + symbol: 'correlation', + valueKind: 'correlation', + }, + ], + phases: [ + { + name: 'only-phase', + state: 'start', + nextState: 'complete', + mode: options.mode ?? 'unordered', + barrier: options.barrier, + expectations, + }, + ], + }; +} + +function observedRequest( + action: string, + overrides: Partial = {} +): ReplayObservedRequest { + return { + origin: 'portal', + method: 'GET', + path: '/portal.php', + query: { action: [action] }, + headers: {}, + cookies: {}, + body: { kind: 'absent' }, + ...overrides, + }; +} + +function issueCodes(result: ReplayFinalizeResult): string[] { + return result.ok ? [] : result.issues.map((issue) => issue.code); +} + +describe('ReplayRun symbols and exact matching', () => { + it('returns isolated safe client inputs without exposing server-issued symbols', () => { + const first = createReplayRun( + replayFixture([replayExpectation('handshake')]), + { runId: 'run-a' } + ); + const second = createReplayRun( + replayFixture([replayExpectation('handshake')]), + { runId: 'run-b' } + ); + + const firstSymbols = first.getGeneratedInputs(); + const secondSymbols = second.getGeneratedInputs(); + expect(firstSymbols).not.toEqual(secondSymbols); + expect(Object.keys(firstSymbols).sort()).toEqual([ + 'mac', + 'password', + 'username', + ]); + expect(firstSymbols['mac']).toMatch(/^02(?::[0-9a-f]{2}){5}$/); + expect(firstSymbols['username']).toMatch(/^test-credential-[0-9a-f]+$/); + expect(firstSymbols['password']).not.toBe(firstSymbols['username']); + expect(firstSymbols).not.toHaveProperty('token'); + expect(firstSymbols).not.toHaveProperty('random'); + expect(firstSymbols).not.toHaveProperty('cookie'); + expect(firstSymbols).not.toHaveProperty('correlation'); + + first.dispose(); + second.dispose(); + }); + + it('requires a referenced value to match exactly', async () => { + const expected = replayExpectation('profile'); + expected.request.headers.exact = { + authorization: { + kind: 'parts', + parts: [ + { kind: 'literal', value: 'Bearer ' }, + { kind: 'ref', symbol: 'username' }, + ], + }, + }; + const run = createReplayRun(replayFixture([expected]), { + runId: 'exact-ref', + }); + const credential = run.getGeneratedInputs()['username']!; + + await expect( + run.request( + observedRequest('profile', { + headers: { + authorization: [`Bearer ${credential}-suffix`], + }, + }) + ) + ).rejects.toMatchObject({ code: 'header-value-mismatch' }); + + await expect( + run.request( + observedRequest('profile', { + headers: { authorization: [`Bearer ${credential}`] }, + }) + ) + ).resolves.toMatchObject({ status: 200 }); + expect(run.finalize().ok).toBe(false); + expect(issueCodes(run.finalize())).toContain('unexpected-request'); + run.dispose(); + }); + + it.each([ + [ + 'absent', + { kind: 'absent' } as ReplayRequestBodyMatcher, + { kind: 'absent' } as const, + ], + [ + 'json', + { + kind: 'json', + exact: { username: { kind: 'ref', symbol: 'username' } }, + present: ['device'], + absent: ['secret'], + } as ReplayRequestBodyMatcher, + { + kind: 'json', + value: { username: 'placeholder', device: 'mag' }, + } as const, + ], + [ + 'form', + { + kind: 'form', + exact: { username: { kind: 'ref', symbol: 'username' } }, + present: ['password'], + absent: ['secret'], + } as ReplayRequestBodyMatcher, + { + kind: 'form', + value: { username: 'placeholder', password: 'test' }, + } as const, + ], + [ + 'text', + { + kind: 'text', + exact: { + kind: 'parts', + parts: [ + { kind: 'literal', value: 'credential=' }, + { kind: 'ref', symbol: 'username' }, + ], + }, + } as ReplayRequestBodyMatcher, + { kind: 'text', value: 'placeholder' } as const, + ], + ])( + 'matches the %s request body discriminator', + async (_name, matcher, body) => { + const expected = replayExpectation('body', { + requestBody: matcher, + }); + const run = createReplayRun(replayFixture([expected]), { + runId: `body-${_name}`, + }); + const symbols = run.getGeneratedInputs(); + const resolvedBody = + body.kind === 'json' + ? { + ...body, + value: { + ...body.value, + username: symbols['username']!, + }, + } + : body.kind === 'form' + ? { + ...body, + value: { + ...body.value, + username: symbols['username']!, + }, + } + : body.kind === 'text' + ? { + ...body, + value: `credential=${symbols['username']!}`, + } + : body; + + await expect( + run.request(observedRequest('body', { body: resolvedBody })) + ).resolves.toMatchObject({ status: 200 }); + expect(run.finalize().ok).toBe(true); + run.dispose(); + } + ); + + it('matches cookie values and attributes without substring comparison', async () => { + const expected = replayExpectation('cookie'); + expected.request.cookies = { + exact: { session: { kind: 'ref', symbol: 'username' } }, + present: ['mac'], + absent: ['legacy'], + attributes: { + session: { + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + }, + }, + }; + const run = createReplayRun(replayFixture([expected]), { + runId: 'cookie-attrs', + }); + const symbols = run.getGeneratedInputs(); + + await expect( + run.request( + observedRequest('cookie', { + cookies: { + mac: { value: symbols['mac']! }, + session: { + value: `${symbols['username']!}-suffix`, + attributes: { + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + }, + }, + }, + }) + ) + ).rejects.toMatchObject({ code: 'cookie-value-mismatch' }); + run.dispose(); + }); +}); + +describe('ReplayRun lifecycle and ledger', () => { + it('records unexpected traffic with only sanitized operation/count/mismatch data', async () => { + const run = createReplayRun( + replayFixture([replayExpectation('profile')]), + { runId: 'ledger-safety' } + ); + const secret = run.getGeneratedInputs()['password']!; + + await expect( + run.request( + observedRequest('unknown', { + path: `/private/${secret}`, + headers: { authorization: [secret] }, + }) + ) + ).rejects.toBeInstanceOf(ReplayRunError); + + const ledger = run.getLedger(); + expect(ledger).toEqual({ + operationCounts: {}, + mismatchCounts: { 'unexpected-request': 1 }, + terminalState: null, + }); + expect(JSON.stringify(ledger)).not.toContain(secret); + expect(JSON.stringify(ledger)).not.toContain('/private/'); + expect(issueCodes(run.finalize())).toEqual( + expect.arrayContaining([ + 'unexpected-request', + 'unmet-cardinality', + 'nonterminal-state', + ]) + ); + run.dispose(); + }); + + it('rejects unmet cardinality and a nonterminal final state', () => { + const run = createReplayRun( + replayFixture([replayExpectation('profile', { min: 2, max: 2 })]), + { runId: 'unmet' } + ); + + const result = run.finalize(); + + expect(issueCodes(result)).toEqual( + expect.arrayContaining(['unmet-cardinality', 'nonterminal-state']) + ); + run.dispose(); + }); + + it('requires the declared expected endpoint to be reached', async () => { + const fixture = replayFixture([replayExpectation('profile')]); + fixture.expectedEndpoint = { + origin: 'portal', + path: '/server/load.php', + }; + const run = createReplayRun(fixture, { + runId: 'expected-endpoint', + }); + + await run.request(observedRequest('profile')); + + expect(issueCodes(run.finalize())).toContain( + 'expected-endpoint-unreached' + ); + run.dispose(); + }); + + it('holds a barrier until its threshold and reports a blocked finalization', async () => { + const run = createReplayRun( + replayFixture( + [replayExpectation('first'), replayExpectation('second')], + { + barrier: { + name: 'both-arrived', + releaseWhenMatched: 2, + }, + } + ), + { runId: 'blocked-barrier' } + ); + + const pending = run.request(observedRequest('first')); + const result = run.finalize(); + expect(issueCodes(result)).toEqual( + expect.arrayContaining([ + 'blocked-barrier', + 'unmet-cardinality', + 'nonterminal-state', + ]) + ); + + run.dispose(); + await expect(pending).rejects.toMatchObject({ code: 'run-disposed' }); + }); + + it('releases barrier responses together and reaches the terminal state', async () => { + const run = createReplayRun( + replayFixture( + [replayExpectation('first'), replayExpectation('second')], + { + barrier: { + name: 'both-arrived', + releaseWhenMatched: 2, + }, + } + ), + { runId: 'released-barrier' } + ); + let firstSettled = false; + const first = run.request(observedRequest('first')).then((response) => { + firstSettled = true; + return response; + }); + await Promise.resolve(); + expect(firstSettled).toBe(false); + + const second = run.request(observedRequest('second')); + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(run.finalize()).toMatchObject({ + ok: true, + ledger: { + operationCounts: { first: 1, second: 1 }, + mismatchCounts: {}, + terminalState: 'complete', + }, + }); + run.dispose(); + }); + + it('erases symbols and rejects requests after disposal', async () => { + const run = createReplayRun( + replayFixture([replayExpectation('profile')]), + { runId: 'disposed' } + ); + expect(Object.keys(run.getGeneratedInputs())).not.toHaveLength(0); + + run.dispose(); + + expect(run.getGeneratedInputs()).toEqual({}); + await expect( + run.request(observedRequest('profile')) + ).rejects.toMatchObject({ code: 'run-disposed' }); + }); + + it('enforces the hard lifetime and inactivity limits using the injected clock', () => { + let now = 10_000; + const hardExpired = createReplayRun( + replayFixture([replayExpectation('profile')]), + { runId: 'hard-expiry', now: () => now } + ); + now += REPLAY_RUN_HARD_LIFETIME_MS; + expect(issueCodes(hardExpired.finalize())).toContain('run-expired'); + hardExpired.dispose(); + + now = 20_000; + const inactive = createReplayRun( + replayFixture([replayExpectation('profile')]), + { runId: 'inactive-expiry', now: () => now } + ); + now += REPLAY_RUN_INACTIVITY_MS; + expect(issueCodes(inactive.finalize())).toContain('run-expired'); + inactive.dispose(); + }); + + it('caps a run at 512 requests', async () => { + const run = createReplayRun( + replayFixture([ + replayExpectation('repeat', { + min: REPLAY_MAX_REQUESTS, + max: REPLAY_MAX_REQUESTS, + }), + ]), + { runId: 'request-cap' } + ); + + for (let index = 0; index < REPLAY_MAX_REQUESTS; index += 1) { + await run.request(observedRequest('repeat')); + } + await expect( + run.request(observedRequest('repeat')) + ).rejects.toMatchObject({ code: 'request-limit-exceeded' }); + run.dispose(); + }); +}); + +describe('ReplayRun response rendering', () => { + it('reuses one generated symbol when a response expectation repeats', async () => { + const expected = replayExpectation('repeat-generated', { + min: 2, + max: 2, + responseBody: { + kind: 'json', + value: { + token: { + kind: 'generate', + symbol: 'issued-token', + valueKind: 'token', + }, + }, + }, + }); + const run = createReplayRun(replayFixture([expected]), { + runId: 'repeat-generated', + }); + + const first = await run.request(observedRequest('repeat-generated')); + const second = await run.request(observedRequest('repeat-generated')); + + expect(first.body).toEqual(second.body); + expect(JSON.parse(first.body.toString())).toEqual({ + token: expect.stringMatching(/^test-token-[0-9a-f]+$/), + }); + expect(run.getGeneratedInputs()).not.toHaveProperty('issued-token'); + expect(run.finalize().ok).toBe(true); + run.dispose(); + }); + + it('renders typed JSON/JSONP/text values and bounded generated bytes', async () => { + const bodies: ReplayResponseBody[] = [ + { + kind: 'json', + value: { token: { kind: 'ref', symbol: 'token' } }, + }, + { + kind: 'jsonp', + callback: 'callback', + value: { ok: true }, + }, + { + kind: 'text', + value: { + kind: 'parts', + parts: [ + { kind: 'literal', value: 'cookie=' }, + { kind: 'ref', symbol: 'cookie' }, + ], + }, + }, + { kind: 'generated', byteLength: 32, byte: 97 }, + ]; + const expectations = bodies.map((body, index) => + replayExpectation(`response-${index}`, { responseBody: body }) + ); + const run = createReplayRun(replayFixture(expectations), { + runId: 'responses', + }); + const responses = []; + for (let index = 0; index < bodies.length; index += 1) { + responses.push( + await run.request(observedRequest(`response-${index}`)) + ); + } + + expect(JSON.parse(responses[0]!.body.toString())).toEqual({ + token: expect.stringMatching(/^test-token-[0-9a-f]+$/), + }); + expect(responses[1]!.body.toString()).toBe('callback({"ok":true});'); + expect(responses[2]!.body.toString()).toMatch( + /^cookie=test-cookie-[0-9a-f]+$/ + ); + expect(responses[3]!.body).toEqual(Buffer.alloc(32, 97)); + expect(run.finalize().ok).toBe(true); + run.dispose(); + }); +}); diff --git a/apps/stalker-mock-server/src/app/replay/replay-run.ts b/apps/stalker-mock-server/src/app/replay/replay-run.ts new file mode 100644 index 000000000..2ebdf2cf9 --- /dev/null +++ b/apps/stalker-mock-server/src/app/replay/replay-run.ts @@ -0,0 +1,389 @@ +import { + REPLAY_MAX_REQUESTS, + REPLAY_RUN_HARD_LIFETIME_MS, + REPLAY_RUN_INACTIVITY_MS, +} from './replay.constants.js'; +import { + matchReplayRequest, + ReplayMismatchCode, +} from './replay-request-matcher.js'; +import { renderReplayResponse } from './replay-response.js'; +import { parseReplayFixture } from './replay-schema.js'; +import { createReplayRunId, ReplaySymbolTable } from './replay-symbols.js'; +import { + ReplayExpectation, + ReplayFixtureV1, + ReplayObservedRequest, + ReplayRenderedResponse, +} from './replay.types.js'; + +export type ReplayRunErrorCode = + | ReplayMismatchCode + | 'unexpected-request' + | 'cardinality-exceeded' + | 'request-limit-exceeded' + | 'run-expired' + | 'run-finalized' + | 'run-disposed'; + +export class ReplayRunError extends Error { + constructor(readonly code: ReplayRunErrorCode) { + super(`Replay run rejected request: ${code}.`); + this.name = 'ReplayRunError'; + } +} + +export interface ReplayLedger { + operationCounts: Record; + mismatchCounts: Record; + terminalState: string | null; +} + +export interface ReplayFinalizeIssue { + code: + | 'unexpected-request' + | 'unmet-cardinality' + | 'blocked-barrier' + | 'nonterminal-state' + | 'expected-endpoint-unreached' + | 'request-limit-exceeded' + | 'run-expired' + | 'run-disposed'; + operation?: string; +} + +export type ReplayFinalizeResult = + | { ok: true; ledger: ReplayLedger } + | { + ok: false; + issues: ReplayFinalizeIssue[]; + ledger: ReplayLedger; + }; + +export interface CreateReplayRunOptions { + runId?: string; + now?: () => number; +} + +interface PendingBarrierResponse { + response: ReplayRenderedResponse; + resolve: (response: ReplayRenderedResponse) => void; + reject: (error: ReplayRunError) => void; +} + +function sortedCounts(counts: Map): Record { + return Object.fromEntries( + [...counts.entries()] + .filter(([, count]) => count > 0) + .sort(([left], [right]) => left.localeCompare(right)) + ); +} + +export class ReplayRun { + readonly runId: string; + + private readonly symbols: ReplaySymbolTable; + private readonly now: () => number; + private readonly createdAt: number; + private lastActivityAt: number; + private phaseIndex = 0; + private state: string; + private requestCount = 0; + private readonly expectationCounts = new Map(); + private readonly operationCounts = new Map(); + private readonly mismatchCounts = new Map(); + private readonly pendingBarrierResponses: PendingBarrierResponse[] = []; + private barrierReleased = false; + private hadUnexpectedRequest = false; + private expectedEndpointReached = false; + private expired = false; + private disposed = false; + private finalizedResult?: ReplayFinalizeResult; + + constructor( + readonly fixture: ReplayFixtureV1, + options: CreateReplayRunOptions = {} + ) { + this.runId = options.runId ?? createReplayRunId(); + this.now = options.now ?? Date.now; + this.createdAt = this.now(); + this.lastActivityAt = this.createdAt; + this.state = fixture.initialState; + this.symbols = new ReplaySymbolTable(this.runId, fixture.symbols); + for (const phase of fixture.phases) { + for (const expectation of phase.expectations) { + this.expectationCounts.set(expectation.id, 0); + } + } + } + + getGeneratedInputs(): Readonly> { + return this.disposed + ? Object.freeze({}) + : this.symbols.clientInputSnapshot(); + } + + getLedger(): ReplayLedger { + return { + operationCounts: sortedCounts(this.operationCounts), + mismatchCounts: sortedCounts(this.mismatchCounts), + terminalState: + this.state === this.fixture.terminalState + ? this.fixture.terminalState + : null, + }; + } + + async request( + observed: ReplayObservedRequest + ): Promise { + this.assertActive(); + this.expireIfNeeded(); + if (this.expired) { + throw new ReplayRunError('run-expired'); + } + if (this.requestCount >= REPLAY_MAX_REQUESTS) { + this.recordMismatch('request-limit-exceeded'); + throw new ReplayRunError('request-limit-exceeded'); + } + this.requestCount += 1; + this.lastActivityAt = this.now(); + + const match = this.findMatch(observed); + if (match === undefined) { + return this.rejectUnexpected('unexpected-request'); + } + if (!match.result.matched) { + return this.rejectUnexpected(match.result.code); + } + + const count = this.expectationCounts.get(match.expectation.id) ?? 0; + if (count >= match.expectation.cardinality.max) { + return this.rejectUnexpected('cardinality-exceeded'); + } + this.expectationCounts.set(match.expectation.id, count + 1); + if ( + observed.origin === this.fixture.expectedEndpoint.origin && + observed.path === this.fixture.expectedEndpoint.path + ) { + this.expectedEndpointReached = true; + } + this.operationCounts.set( + match.expectation.operation, + (this.operationCounts.get(match.expectation.operation) ?? 0) + 1 + ); + + const response = renderReplayResponse( + match.expectation.response, + this.symbols + ); + const phase = this.fixture.phases[this.phaseIndex]; + const barrier = phase?.barrier; + + if (barrier !== undefined && !this.barrierReleased) { + const held = new Promise( + (resolve, reject) => { + this.pendingBarrierResponses.push({ + response, + resolve, + reject, + }); + } + ); + const phaseMatches = phase.expectations.reduce( + (total, expectation) => + total + (this.expectationCounts.get(expectation.id) ?? 0), + 0 + ); + if (phaseMatches >= barrier.releaseWhenMatched) { + this.barrierReleased = true; + const pending = this.pendingBarrierResponses.splice(0); + pending.forEach((item) => item.resolve(item.response)); + } + this.advanceCompletedPhase(); + return held; + } + + this.advanceCompletedPhase(); + return response; + } + + finalize(): ReplayFinalizeResult { + if (this.finalizedResult !== undefined) { + return this.finalizedResult; + } + + this.expireIfNeeded(); + const issues: ReplayFinalizeIssue[] = []; + if (this.disposed) { + issues.push({ code: 'run-disposed' }); + } + if (this.expired) { + issues.push({ code: 'run-expired' }); + } + if (this.hadUnexpectedRequest && this.fixture.failOnUnexpectedRequest) { + issues.push({ code: 'unexpected-request' }); + } + for (const phase of this.fixture.phases) { + for (const expectation of phase.expectations) { + if ( + (this.expectationCounts.get(expectation.id) ?? 0) < + expectation.cardinality.min + ) { + issues.push({ + code: 'unmet-cardinality', + operation: expectation.operation, + }); + } + } + } + if (this.pendingBarrierResponses.length > 0 && !this.barrierReleased) { + issues.push({ code: 'blocked-barrier' }); + } + if (this.state !== this.fixture.terminalState) { + issues.push({ code: 'nonterminal-state' }); + } + if (!this.expectedEndpointReached) { + issues.push({ code: 'expected-endpoint-unreached' }); + } + if (this.mismatchCounts.has('request-limit-exceeded')) { + issues.push({ code: 'request-limit-exceeded' }); + } + + const ledger = this.getLedger(); + this.finalizedResult = + issues.length === 0 + ? { ok: true, ledger } + : { ok: false, issues, ledger }; + return this.finalizedResult; + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + const pending = this.pendingBarrierResponses.splice(0); + pending.forEach((item) => + item.reject(new ReplayRunError('run-disposed')) + ); + this.symbols.clear(); + } + + private assertActive(): void { + if (this.disposed) { + throw new ReplayRunError('run-disposed'); + } + if (this.finalizedResult !== undefined) { + throw new ReplayRunError('run-finalized'); + } + } + + private expireIfNeeded(): void { + if (this.expired || this.disposed) { + return; + } + const current = this.now(); + if ( + current - this.createdAt >= REPLAY_RUN_HARD_LIFETIME_MS || + current - this.lastActivityAt >= REPLAY_RUN_INACTIVITY_MS + ) { + this.expired = true; + this.recordMismatch('run-expired'); + const pending = this.pendingBarrierResponses.splice(0); + pending.forEach((item) => + item.reject(new ReplayRunError('run-expired')) + ); + } + } + + private currentExpectations(): ReplayExpectation[] { + const phase = this.fixture.phases[this.phaseIndex]; + if (phase === undefined) { + return []; + } + if (phase.mode === 'unordered') { + return phase.expectations; + } + const next = phase.expectations.find( + (expectation) => + (this.expectationCounts.get(expectation.id) ?? 0) < + expectation.cardinality.min + ); + return next === undefined ? [] : [next]; + } + + private findMatch(observed: ReplayObservedRequest): + | { + expectation: ReplayExpectation; + result: ReturnType; + } + | undefined { + let firstMismatch: + | { + expectation: ReplayExpectation; + result: ReturnType; + } + | undefined; + for (const expectation of this.currentExpectations()) { + const result = matchReplayRequest( + expectation, + observed, + this.symbols + ); + if (result.matched) { + return { expectation, result }; + } + if ( + firstMismatch === undefined && + expectation.origin === observed.origin && + expectation.method === observed.method && + expectation.path === observed.path + ) { + firstMismatch = { expectation, result }; + } + } + return firstMismatch; + } + + private rejectUnexpected( + code: ReplayMismatchCode | 'unexpected-request' | 'cardinality-exceeded' + ): never { + this.hadUnexpectedRequest = true; + this.recordMismatch(code); + throw new ReplayRunError(code); + } + + private recordMismatch(code: string): void { + this.mismatchCounts.set(code, (this.mismatchCounts.get(code) ?? 0) + 1); + } + + private advanceCompletedPhase(): void { + const phase = this.fixture.phases[this.phaseIndex]; + if (phase === undefined) { + return; + } + const minimumsMet = phase.expectations.every( + (expectation) => + (this.expectationCounts.get(expectation.id) ?? 0) >= + expectation.cardinality.min + ); + if ( + !minimumsMet || + (phase.barrier !== undefined && !this.barrierReleased) + ) { + return; + } + + this.state = phase.nextState; + this.phaseIndex += 1; + this.barrierReleased = false; + } +} + +export function createReplayRun( + fixture: ReplayFixtureV1 | unknown, + options: CreateReplayRunOptions = {} +): ReplayRun { + return new ReplayRun(parseReplayFixture(fixture), options); +} diff --git a/apps/stalker-mock-server/src/app/replay/replay-schema.spec.ts b/apps/stalker-mock-server/src/app/replay/replay-schema.spec.ts new file mode 100644 index 000000000..21d8e5ca3 --- /dev/null +++ b/apps/stalker-mock-server/src/app/replay/replay-schema.spec.ts @@ -0,0 +1,474 @@ +/* eslint-disable max-lines, @typescript-eslint/no-non-null-assertion -- Boundary mutations target values built by the same local valid-fixture helper. */ +import { + REPLAY_MAX_EXPECTATIONS, + REPLAY_MAX_FIXTURE_BYTES, + REPLAY_MAX_GENERATED_BODY_BYTES, + REPLAY_MAX_PHASES, + REPLAY_MAX_REQUESTS, + REPLAY_RUN_HARD_LIFETIME_MS, + REPLAY_RUN_INACTIVITY_MS, +} from './replay.constants.js'; +import { + parseReplayFixture, + parseReplayFixtureText, + ReplaySchemaError, +} from './replay-schema.js'; + +function expectation( + id: string, + body: unknown = { kind: 'absent' }, + responseBody: unknown = { kind: 'empty' } +): Record { + return { + id, + operation: id, + origin: 'portal', + method: 'GET', + path: '/portal.php', + request: { + query: { + exact: { action: id }, + present: [], + absent: ['forbidden'], + }, + headers: { + exact: {}, + present: [], + absent: ['authorization'], + }, + cookies: { + exact: {}, + present: [], + absent: [], + attributes: {}, + }, + body, + }, + response: { + status: 200, + headers: { + 'content-type': ['application/json'], + 'set-cookie': [ + { + kind: 'parts', + parts: [ + { kind: 'literal', value: 'session=' }, + { kind: 'ref', symbol: 'cookie' }, + { kind: 'literal', value: '; Path=/; HttpOnly' }, + ], + }, + ], + }, + body: responseBody, + }, + cardinality: { min: 1, max: 1 }, + }; +} + +function fixture(): Record { + return { + schemaVersion: 1, + scenarioId: 'schema-contract', + description: 'Synthetic schema contract fixture.', + origins: { + portal: {}, + auth: {}, + }, + entry: { origin: 'portal', path: '/c/' }, + expectedEndpoint: { origin: 'portal', path: '/portal.php' }, + initialState: 'start', + terminalState: 'complete', + failOnUnexpectedRequest: true, + symbols: [ + { kind: 'generate', symbol: 'mac', valueKind: 'mac' }, + { + kind: 'generate', + symbol: 'credential', + valueKind: 'credential', + }, + { kind: 'generate', symbol: 'token', valueKind: 'token' }, + { kind: 'generate', symbol: 'random', valueKind: 'random' }, + { kind: 'generate', symbol: 'cookie', valueKind: 'cookie' }, + { + kind: 'generate', + symbol: 'correlation', + valueKind: 'correlation', + }, + ], + phases: [ + { + name: 'requests', + state: 'start', + nextState: 'complete', + mode: 'unordered', + barrier: { + name: 'all-request-kinds', + releaseWhenMatched: 4, + }, + expectations: [ + expectation('empty', { kind: 'absent' }, { kind: 'empty' }), + expectation( + 'json', + { + kind: 'json', + exact: { + mac: { kind: 'ref', symbol: 'mac' }, + }, + present: ['device'], + absent: ['password'], + }, + { + kind: 'json', + value: { + js: { + token: { + kind: 'ref', + symbol: 'token', + }, + }, + }, + } + ), + expectation( + 'form', + { + kind: 'form', + exact: { + username: { + kind: 'ref', + symbol: 'credential', + }, + }, + present: ['password'], + absent: ['secret'], + }, + { + kind: 'jsonp', + callback: 'callback', + value: { js: true }, + } + ), + expectation( + 'text', + { + kind: 'text', + exact: { + kind: 'parts', + parts: [ + { kind: 'literal', value: 'request-' }, + { + kind: 'ref', + symbol: 'correlation', + }, + ], + }, + }, + { kind: 'text', value: 'synthetic' } + ), + expectation( + 'generated', + { kind: 'absent' }, + { + kind: 'generated', + byteLength: 1024, + byte: 97, + } + ), + ], + }, + ], + }; +} + +function expectSchemaCode(value: unknown, code: string): void { + try { + parseReplayFixture(value); + throw new Error('fixture unexpectedly passed'); + } catch (error) { + expect(error).toBeInstanceOf(ReplaySchemaError); + expect((error as ReplaySchemaError).issues).toContainEqual( + expect.objectContaining({ code }) + ); + } +} + +describe('replay fixture schema v1', () => { + it('accepts the complete response, body matcher, symbol, phase, and barrier unions', () => { + const parsed = parseReplayFixture(fixture()); + + expect(parsed.schemaVersion).toBe(1); + expect(parsed.origins).toEqual({ portal: {}, auth: {} }); + expect(parsed.phases[0]?.barrier).toEqual({ + name: 'all-request-kinds', + releaseWhenMatched: 4, + }); + }); + + it.each([ + ['unknown response', { kind: 'binary', value: 'x' }], + ['empty response with data', { kind: 'empty', value: null }], + [ + 'invalid JSONP callback', + { kind: 'jsonp', callback: 'a.b', value: {} }, + ], + [ + 'invalid generated byte', + { kind: 'generated', byteLength: 1, byte: 256 }, + ], + ])('rejects %s', (_label, responseBody) => { + const value = fixture(); + ( + ( + (value['phases'] as Record[])[0]![ + 'expectations' + ] as Record[] + )[0]!['response'] as Record + )['body'] = responseBody; + + expectSchemaCode(value, 'invalid-response-body'); + }); + + it.each([ + { kind: 'missing' }, + { kind: 'json', exact: {}, present: [], absent: [], value: {} }, + { kind: 'form', exact: {}, present: [], absent: [], extra: true }, + { kind: 'text', exact: 'x', present: [] }, + ])('rejects an invalid request body matcher %#', (body) => { + const value = fixture(); + ( + (value['phases'] as Record[])[0]![ + 'expectations' + ] as Record[] + )[0]!['request'] = { + ...(( + (value['phases'] as Record[])[0]![ + 'expectations' + ] as Record[] + )[0]!['request'] as Record), + body, + }; + + expectSchemaCode(value, 'invalid-request-body'); + }); + + it('requires lower-case response headers with array values', () => { + const value = fixture(); + const response = ( + (value['phases'] as Record[])[0]![ + 'expectations' + ] as Record[] + )[0]!['response'] as Record; + response['headers'] = { + Location: '/next', + 'set-cookie': 'session=x', + }; + + expectSchemaCode(value, 'invalid-response-headers'); + }); + + it('rejects untyped symbols and free-form interpolation', () => { + const untyped = fixture(); + (untyped['symbols'] as unknown[]).push({ + generate: 'token', + value: 'real-looking-secret', + }); + expectSchemaCode(untyped, 'invalid-symbol'); + + const interpolated = fixture(); + const response = ( + (interpolated['phases'] as Record[])[0]![ + 'expectations' + ] as Record[] + )[0]!['response'] as Record; + response['headers'] = { + authorization: ['Bearer ${token}'], + }; + expectSchemaCode(interpolated, 'free-form-interpolation'); + }); + + it('requires named origins and valid entry/expected endpoint references', () => { + const value = fixture(); + value['entry'] = { origin: 'missing', path: '/c/' }; + + expectSchemaCode(value, 'unknown-origin'); + }); + + it('requires exact cardinality until range transitions are modeled explicitly', () => { + const value = fixture(); + const firstExpectation = ( + (value['phases'] as Record[])[0]![ + 'expectations' + ] as Record[] + )[0]!; + firstExpectation['cardinality'] = { min: 1, max: 2 }; + + expectSchemaCode(value, 'non-exact-cardinality'); + }); + + it('rejects a scenario whose minimum cardinality exceeds the run request cap', () => { + const value = fixture(); + const phase = (value['phases'] as Record[])[0]!; + phase['barrier'] = undefined; + phase['expectations'] = [expectation('first'), expectation('second')]; + ( + (phase['expectations'] as Record[])[0]![ + 'cardinality' + ] as Record + )['min'] = 256; + ( + (phase['expectations'] as Record[])[0]![ + 'cardinality' + ] as Record + )['max'] = 256; + ( + (phase['expectations'] as Record[])[1]![ + 'cardinality' + ] as Record + )['min'] = 257; + ( + (phase['expectations'] as Record[])[1]![ + 'cardinality' + ] as Record + )['max'] = 257; + + expectSchemaCode(value, 'too-many-requests'); + }); + + it('rejects an unordered sibling ref to a response-generated symbol', () => { + const value = fixture(); + const phase = (value['phases'] as Record[])[0]!; + phase['barrier'] = undefined; + phase['expectations'] = [ + expectation( + 'issuer', + { kind: 'absent' }, + { + kind: 'json', + value: { + token: { + kind: 'generate', + symbol: 'phase-token', + valueKind: 'token', + }, + }, + } + ), + expectation('consumer'), + ]; + const consumer = ( + phase['expectations'] as Record[] + )[1]!; + const headers = (consumer['request'] as Record)[ + 'headers' + ] as Record; + headers['absent'] = []; + headers['exact'] = { + authorization: { kind: 'ref', symbol: 'phase-token' }, + }; + + expectSchemaCode(value, 'unknown-symbol'); + }); + + it('freezes the validated graph against post-validation mutation', () => { + const parsed = parseReplayFixture(fixture()); + + expect(() => { + parsed.phases.push(parsed.phases[0]!); + }).toThrow(TypeError); + expect(() => { + parsed.phases[0]!.expectations[0]!.response.headers[ + 'content-type' + ]!.push('text/plain'); + }).toThrow(TypeError); + }); +}); + +describe('replay fixture limits', () => { + it('owns the exact runtime limits', () => { + expect(REPLAY_MAX_FIXTURE_BYTES).toBe(1024 * 1024); + expect(REPLAY_MAX_PHASES).toBe(128); + expect(REPLAY_MAX_EXPECTATIONS).toBe(512); + expect(REPLAY_MAX_REQUESTS).toBe(512); + expect(REPLAY_MAX_GENERATED_BODY_BYTES).toBe(16 * 1024 * 1024); + expect(REPLAY_RUN_HARD_LIFETIME_MS).toBe(10 * 60 * 1000); + expect(REPLAY_RUN_INACTIVITY_MS).toBe(2 * 60 * 1000); + }); + + it('accepts exactly 1 MiB and rejects one byte more', () => { + const value = fixture(); + value['description'] = ''; + const base = JSON.stringify(value); + value['description'] = 'x'.repeat( + REPLAY_MAX_FIXTURE_BYTES - Buffer.byteLength(base) + ); + const exact = JSON.stringify(value); + + expect(Buffer.byteLength(exact)).toBe(REPLAY_MAX_FIXTURE_BYTES); + expect(() => parseReplayFixtureText(exact)).not.toThrow(); + expectSchemaCode(`${exact} `, 'fixture-too-large'); + }); + + it('accepts 128 phases and rejects 129', () => { + const value = fixture(); + value['phases'] = Array.from( + { length: REPLAY_MAX_PHASES }, + (_, index) => ({ + name: `phase-${index}`, + state: `state-${index}`, + nextState: + index === REPLAY_MAX_PHASES - 1 + ? 'complete' + : `state-${index + 1}`, + mode: 'ordered', + expectations: [expectation(`operation-${index}`)], + }) + ); + value['initialState'] = 'state-0'; + + expect(() => parseReplayFixture(value)).not.toThrow(); + (value['phases'] as unknown[]).push({ + name: 'phase-over-limit', + state: 'complete', + nextState: 'complete', + mode: 'ordered', + expectations: [expectation('over-limit')], + }); + expectSchemaCode(value, 'too-many-phases'); + }); + + it('accepts 512 expectations and rejects 513', () => { + const value = fixture(); + const phase = (value['phases'] as Record[])[0]!; + phase['barrier'] = undefined; + phase['expectations'] = Array.from( + { length: REPLAY_MAX_EXPECTATIONS }, + (_, index) => expectation(`operation-${index}`) + ); + + expect(() => parseReplayFixture(value)).not.toThrow(); + (phase['expectations'] as unknown[]).push(expectation('over-limit')); + expectSchemaCode(value, 'too-many-expectations'); + }); + + it('accepts a 16 MiB generated body and rejects one byte more', () => { + const value = fixture(); + const response = ( + (value['phases'] as Record[])[0]![ + 'expectations' + ] as Record[] + )[0]!['response'] as Record; + response['body'] = { + kind: 'generated', + byteLength: REPLAY_MAX_GENERATED_BODY_BYTES, + byte: 0, + }; + + expect(() => parseReplayFixture(value)).not.toThrow(); + response['body'] = { + kind: 'generated', + byteLength: REPLAY_MAX_GENERATED_BODY_BYTES + 1, + byte: 0, + }; + expectSchemaCode(value, 'generated-body-too-large'); + }); +}); diff --git a/apps/stalker-mock-server/src/app/replay/replay-schema.ts b/apps/stalker-mock-server/src/app/replay/replay-schema.ts new file mode 100644 index 000000000..50fb35966 --- /dev/null +++ b/apps/stalker-mock-server/src/app/replay/replay-schema.ts @@ -0,0 +1,1471 @@ +/* eslint-disable max-lines -- Keep the security-sensitive v1 fixture grammar and its fail-closed checks in one auditable validator. */ +import { + REPLAY_COOKIE_ATTRIBUTE_NAMES, + REPLAY_HTTP_METHODS, + REPLAY_MAX_EXPECTATIONS, + REPLAY_MAX_FIXTURE_BYTES, + REPLAY_MAX_GENERATED_BODY_BYTES, + REPLAY_MAX_PHASES, + REPLAY_MAX_REQUESTS, + REPLAY_SCHEMA_VERSION, + REPLAY_SYMBOL_VALUE_KINDS, +} from './replay.constants.js'; +import { + ReplayFixtureV1, + ReplayGenerateNode, + ReplayTemplateString, +} from './replay.types.js'; + +export interface ReplaySchemaIssue { + code: string; + path: string; + message: string; +} + +function schemaErrorMessage(issues: ReplaySchemaIssue[]): string { + const firstIssue = issues[0]; + return firstIssue === undefined + ? 'Replay fixture is invalid.' + : `Replay fixture is invalid: ${firstIssue.code} at ${firstIssue.path}.`; +} + +export class ReplaySchemaError extends Error { + constructor(readonly issues: ReplaySchemaIssue[]) { + super(schemaErrorMessage(issues)); + this.name = 'ReplaySchemaError'; + } +} + +interface ValidationContext { + issues: ReplaySchemaIssue[]; + symbols: Set; + declaredSymbols: Set; +} + +const SAFE_LABEL = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const SAFE_ORIGIN = /^[a-z][a-z0-9-]{0,31}$/; +const LOWER_CASE_HEADER = /^[a-z0-9!#$%&'*+.^_`|~-]+$/; +const JSONP_CALLBACK = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/; +const FREE_FORM_INTERPOLATION = /\$\{|{{|<%|%\{/; + +function issue( + context: ValidationContext, + code: string, + path: string, + message: string +): void { + context.issues.push({ code, path, message }); +} + +function isRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasExactKeys( + value: Record, + allowed: readonly string[] +): boolean { + const allowedKeys = new Set(allowed); + return Object.keys(value).every((key) => allowedKeys.has(key)); +} + +function validateSafeString( + value: unknown, + path: string, + context: ValidationContext, + code = 'invalid-label' +): value is string { + if (typeof value !== 'string' || !SAFE_LABEL.test(value)) { + issue(context, code, path, 'Expected a bounded safe label.'); + return false; + } + return true; +} + +function validateLiteralString( + value: unknown, + path: string, + context: ValidationContext +): value is string { + if (typeof value !== 'string') { + issue(context, 'invalid-string', path, 'Expected a string.'); + return false; + } + if (FREE_FORM_INTERPOLATION.test(value)) { + issue( + context, + 'free-form-interpolation', + path, + 'Use typed ref/parts nodes instead of interpolation.' + ); + return false; + } + return true; +} + +function validatePath( + value: unknown, + path: string, + context: ValidationContext +): value is string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 2048 || + !value.startsWith('/') || + value.includes('?') || + value.includes('#') || + value.includes('\\') || + FREE_FORM_INTERPOLATION.test(value) + ) { + issue( + context, + 'invalid-path', + path, + 'Expected a bounded absolute path without query, fragment, or interpolation.' + ); + return false; + } + return true; +} + +function validateRefNode( + value: unknown, + path: string, + context: ValidationContext +): boolean { + if ( + !isRecord(value) || + value['kind'] !== 'ref' || + !hasExactKeys(value, ['kind', 'symbol']) + ) { + issue(context, 'invalid-symbol', path, 'Expected a typed ref node.'); + return false; + } + if ( + !validateSafeString( + value['symbol'], + `${path}.symbol`, + context, + 'invalid-symbol' + ) + ) { + return false; + } + if (!context.symbols.has(value['symbol'])) { + issue( + context, + 'unknown-symbol', + `${path}.symbol`, + 'References must name a generated run-scoped symbol.' + ); + return false; + } + return true; +} + +function validateGenerateNode( + value: unknown, + path: string, + context: ValidationContext, + register: boolean +): value is ReplayGenerateNode { + if ( + !isRecord(value) || + value['kind'] !== 'generate' || + !hasExactKeys(value, ['kind', 'symbol', 'valueKind']) + ) { + issue( + context, + 'invalid-symbol', + path, + 'Expected a typed generate node.' + ); + return false; + } + const validSymbol = validateSafeString( + value['symbol'], + `${path}.symbol`, + context, + 'invalid-symbol' + ); + if ( + typeof value['valueKind'] !== 'string' || + !REPLAY_SYMBOL_VALUE_KINDS.includes( + value['valueKind'] as (typeof REPLAY_SYMBOL_VALUE_KINDS)[number] + ) + ) { + issue( + context, + 'invalid-symbol', + `${path}.valueKind`, + 'Unknown generated symbol value kind.' + ); + return false; + } + if (validSymbol && register) { + if (context.declaredSymbols.has(value['symbol'] as string)) { + issue( + context, + 'duplicate-symbol', + `${path}.symbol`, + 'A generated symbol name may be declared only once.' + ); + return false; + } + context.declaredSymbols.add(value['symbol'] as string); + context.symbols.add(value['symbol'] as string); + } + return validSymbol; +} + +function validatePartsNode( + value: unknown, + path: string, + context: ValidationContext +): boolean { + if ( + !isRecord(value) || + value['kind'] !== 'parts' || + !hasExactKeys(value, ['kind', 'parts']) || + !Array.isArray(value['parts']) || + value['parts'].length === 0 || + value['parts'].length > 64 + ) { + issue( + context, + 'invalid-symbol', + path, + 'Expected a non-empty typed parts node.' + ); + return false; + } + + let valid = true; + value['parts'].forEach((part, index) => { + const partPath = `${path}.parts[${index}]`; + if (!isRecord(part)) { + issue( + context, + 'invalid-symbol', + partPath, + 'Parts may contain only literal and ref nodes.' + ); + valid = false; + return; + } + if (part['kind'] === 'literal') { + if ( + !hasExactKeys(part, ['kind', 'value']) || + !validateLiteralString( + part['value'], + `${partPath}.value`, + context + ) + ) { + issue( + context, + 'invalid-symbol', + partPath, + 'Literal parts require exactly kind and value.' + ); + valid = false; + } + return; + } + if (part['kind'] === 'ref') { + valid = validateRefNode(part, partPath, context) && valid; + return; + } + issue( + context, + 'invalid-symbol', + partPath, + 'Parts may contain only literal and ref nodes.' + ); + valid = false; + }); + return valid; +} + +function validateTemplateString( + value: unknown, + path: string, + context: ValidationContext, + allowGenerate: boolean +): value is ReplayTemplateString { + if (typeof value === 'string') { + return validateLiteralString(value, path, context); + } + if (!isRecord(value)) { + issue( + context, + 'invalid-symbol', + path, + 'Expected a literal string or a typed symbol node.' + ); + return false; + } + if (value['kind'] === 'ref') { + return validateRefNode(value, path, context); + } + if (value['kind'] === 'parts') { + return validatePartsNode(value, path, context); + } + if (value['kind'] === 'generate' && allowGenerate) { + return validateGenerateNode(value, path, context, true); + } + issue( + context, + 'invalid-symbol', + path, + 'Expected a literal string or an allowed typed symbol node.' + ); + return false; +} + +function validateTemplateValue( + value: unknown, + path: string, + context: ValidationContext, + allowGenerate: boolean, + depth = 0 +): boolean { + if (depth > 64) { + issue( + context, + 'template-too-deep', + path, + 'Template nesting exceeds the deterministic limit.' + ); + return false; + } + if ( + value === null || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return true; + } + if (typeof value === 'string') { + return validateLiteralString(value, path, context); + } + if (Array.isArray(value)) { + return value.reduce( + (valid, item, index) => + validateTemplateValue( + item, + `${path}[${index}]`, + context, + allowGenerate, + depth + 1 + ) && valid, + true + ); + } + if (!isRecord(value)) { + issue( + context, + 'invalid-template', + path, + 'Expected JSON template data.' + ); + return false; + } + + if (value['kind'] === 'ref') { + return validateRefNode(value, path, context); + } + if (value['kind'] === 'parts') { + return validatePartsNode(value, path, context); + } + if (value['kind'] === 'generate') { + if (!allowGenerate) { + issue( + context, + 'invalid-symbol', + path, + 'Request matchers cannot generate values.' + ); + return false; + } + return validateGenerateNode(value, path, context, true); + } + + return Object.entries(value).reduce( + (valid, [key, item]) => + validateLiteralString(key, `${path}.`, context) && + validateTemplateValue( + item, + `${path}.${key}`, + context, + allowGenerate, + depth + 1 + ) && + valid, + true + ); +} + +function validateStringList( + value: unknown, + path: string, + context: ValidationContext, + lowerCase = false +): value is string[] { + if (!Array.isArray(value)) { + issue(context, 'invalid-matchers', path, 'Expected an array of keys.'); + return false; + } + let valid = true; + const seen = new Set(); + value.forEach((item, index) => { + if ( + typeof item !== 'string' || + item.length === 0 || + (lowerCase && item !== item.toLowerCase()) || + seen.has(item) + ) { + issue( + context, + 'invalid-matchers', + `${path}[${index}]`, + 'Matcher keys must be unique non-empty strings.' + ); + valid = false; + } else { + seen.add(item); + } + }); + return valid; +} + +function validateFieldMatchers( + value: unknown, + path: string, + context: ValidationContext, + options: { + lowerCaseKeys?: boolean; + allowArrays?: boolean; + allowJson?: boolean; + } = {} +): boolean { + if ( + !isRecord(value) || + !hasExactKeys(value, ['exact', 'present', 'absent']) || + !isRecord(value['exact']) + ) { + issue( + context, + 'invalid-matchers', + path, + 'Expected exact, present, and absent matcher sets.' + ); + return false; + } + + let valid = + validateStringList( + value['present'], + `${path}.present`, + context, + options.lowerCaseKeys + ) && + validateStringList( + value['absent'], + `${path}.absent`, + context, + options.lowerCaseKeys + ); + + const allKeys = [ + ...Object.keys(value['exact']), + ...((value['present'] as string[]) ?? []), + ...((value['absent'] as string[]) ?? []), + ]; + if (new Set(allKeys).size !== allKeys.length) { + issue( + context, + 'invalid-matchers', + path, + 'A matcher key cannot appear in more than one set.' + ); + valid = false; + } + + for (const [key, expected] of Object.entries(value['exact'])) { + if ( + key.length === 0 || + (options.lowerCaseKeys && key !== key.toLowerCase()) + ) { + issue( + context, + 'invalid-matchers', + `${path}.exact`, + 'Matcher keys must be non-empty and normalized.' + ); + valid = false; + continue; + } + if (options.allowArrays && Array.isArray(expected)) { + if ( + expected.length === 0 || + !expected.every((item, index) => + validateTemplateString( + item, + `${path}.exact.${key}[${index}]`, + context, + false + ) + ) + ) { + valid = false; + } + } else if (options.allowJson) { + valid = + validateTemplateValue( + expected, + `${path}.exact.${key}`, + context, + false + ) && valid; + } else { + valid = + validateTemplateString( + expected, + `${path}.exact.${key}`, + context, + false + ) && valid; + } + } + + return valid; +} + +function validateCookieMatchers( + value: unknown, + path: string, + context: ValidationContext +): boolean { + if ( + !isRecord(value) || + !hasExactKeys(value, ['exact', 'present', 'absent', 'attributes']) + ) { + issue( + context, + 'invalid-cookie-matchers', + path, + 'Expected cookie value and attribute matchers.' + ); + return false; + } + const base = { + exact: value['exact'], + present: value['present'], + absent: value['absent'], + }; + let valid = validateFieldMatchers(base, path, context); + if (!isRecord(value['attributes'])) { + issue( + context, + 'invalid-cookie-matchers', + `${path}.attributes`, + 'Expected cookie attribute matcher map.' + ); + return false; + } + + for (const [cookieName, attributes] of Object.entries( + value['attributes'] + )) { + if (!isRecord(attributes)) { + issue( + context, + 'invalid-cookie-matchers', + `${path}.attributes.${cookieName}`, + 'Expected cookie attributes.' + ); + valid = false; + continue; + } + if (!hasExactKeys(attributes, REPLAY_COOKIE_ATTRIBUTE_NAMES)) { + issue( + context, + 'invalid-cookie-matchers', + `${path}.attributes.${cookieName}`, + 'Unknown cookie attribute matcher.' + ); + valid = false; + } + for (const [attribute, expected] of Object.entries(attributes)) { + const attributePath = `${path}.attributes.${cookieName}.${attribute}`; + if (attribute === 'secure' || attribute === 'httpOnly') { + if (typeof expected !== 'boolean') { + issue( + context, + 'invalid-cookie-matchers', + attributePath, + 'Expected a boolean cookie attribute.' + ); + valid = false; + } + } else if (attribute === 'maxAge') { + if (!Number.isInteger(expected) || (expected as number) < 0) { + issue( + context, + 'invalid-cookie-matchers', + attributePath, + 'Expected a non-negative integer maxAge.' + ); + valid = false; + } + } else if (attribute === 'sameSite') { + if (!['strict', 'lax', 'none'].includes(expected as string)) { + issue( + context, + 'invalid-cookie-matchers', + attributePath, + 'Expected strict, lax, or none.' + ); + valid = false; + } + } else { + valid = + validateTemplateString( + expected, + attributePath, + context, + false + ) && valid; + } + } + } + return valid; +} + +function validateRequestBody( + value: unknown, + path: string, + context: ValidationContext +): boolean { + if (!isRecord(value) || typeof value['kind'] !== 'string') { + issue( + context, + 'invalid-request-body', + path, + 'Expected a discriminated request body matcher.' + ); + return false; + } + if (value['kind'] === 'absent') { + if (hasExactKeys(value, ['kind'])) { + return true; + } + } else if (value['kind'] === 'json' || value['kind'] === 'form') { + if (hasExactKeys(value, ['kind', 'exact', 'present', 'absent'])) { + const matchers = { + exact: value['exact'], + present: value['present'], + absent: value['absent'], + }; + if ( + validateFieldMatchers(matchers, path, context, { + allowJson: value['kind'] === 'json', + }) + ) { + return true; + } + } + } else if (value['kind'] === 'text') { + if ( + hasExactKeys(value, ['kind', 'exact']) && + validateTemplateString( + value['exact'], + `${path}.exact`, + context, + false + ) + ) { + return true; + } + } + issue( + context, + 'invalid-request-body', + path, + 'Unknown or malformed request body matcher.' + ); + return false; +} + +function validateResponseHeaders( + value: unknown, + path: string, + context: ValidationContext +): boolean { + if (!isRecord(value)) { + issue( + context, + 'invalid-response-headers', + path, + 'Expected a lower-case response header map.' + ); + return false; + } + let valid = true; + for (const [name, values] of Object.entries(value)) { + if ( + name !== name.toLowerCase() || + !LOWER_CASE_HEADER.test(name) || + !Array.isArray(values) || + values.length === 0 + ) { + issue( + context, + 'invalid-response-headers', + `${path}.${name}`, + 'Response headers require lower-case names and non-empty arrays.' + ); + valid = false; + continue; + } + values.forEach((headerValue, index) => { + valid = + validateTemplateString( + headerValue, + `${path}.${name}[${index}]`, + context, + true + ) && valid; + }); + } + return valid; +} + +function validateResponseBody( + value: unknown, + path: string, + context: ValidationContext +): boolean { + if (!isRecord(value) || typeof value['kind'] !== 'string') { + issue( + context, + 'invalid-response-body', + path, + 'Expected a discriminated response body.' + ); + return false; + } + + let valid = true; + switch (value['kind']) { + case 'empty': + valid = hasExactKeys(value, ['kind']); + break; + case 'json': + valid = + hasExactKeys(value, ['kind', 'value']) && + validateTemplateValue( + value['value'], + `${path}.value`, + context, + true + ); + break; + case 'jsonp': + valid = + hasExactKeys(value, ['kind', 'callback', 'value']) && + typeof value['callback'] === 'string' && + JSONP_CALLBACK.test(value['callback']) && + !['__proto__', 'constructor', 'prototype'].includes( + value['callback'] + ) && + validateTemplateValue( + value['value'], + `${path}.value`, + context, + true + ); + break; + case 'text': + valid = + hasExactKeys(value, ['kind', 'value']) && + validateTemplateString( + value['value'], + `${path}.value`, + context, + true + ); + break; + case 'generated': + if ( + !hasExactKeys(value, ['kind', 'byteLength', 'byte']) || + !Number.isInteger(value['byteLength']) || + (value['byteLength'] as number) < 0 || + !Number.isInteger(value['byte']) || + (value['byte'] as number) < 0 || + (value['byte'] as number) > 255 + ) { + valid = false; + } else if ( + (value['byteLength'] as number) > + REPLAY_MAX_GENERATED_BODY_BYTES + ) { + issue( + context, + 'generated-body-too-large', + `${path}.byteLength`, + 'Generated response exceeds the 16 MiB limit.' + ); + return false; + } + break; + default: + valid = false; + } + + if (!valid) { + issue( + context, + 'invalid-response-body', + path, + 'Unknown or malformed response body.' + ); + } + return valid; +} + +function validateResponse( + value: unknown, + path: string, + context: ValidationContext +): boolean { + if ( + !isRecord(value) || + !hasExactKeys(value, ['status', 'headers', 'body']) + ) { + issue( + context, + 'invalid-response', + path, + 'Expected status, headers, and body.' + ); + return false; + } + let valid = true; + if ( + !Number.isInteger(value['status']) || + (value['status'] as number) < 100 || + (value['status'] as number) > 599 + ) { + issue( + context, + 'invalid-response', + `${path}.status`, + 'Expected an HTTP status from 100 through 599.' + ); + valid = false; + } + return ( + validateResponseHeaders(value['headers'], `${path}.headers`, context) && + validateResponseBody(value['body'], `${path}.body`, context) && + valid + ); +} + +function validateExpectation( + value: unknown, + path: string, + context: ValidationContext, + origins: Set, + ids: Set +): number { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'id', + 'operation', + 'origin', + 'method', + 'path', + 'request', + 'response', + 'cardinality', + ]) + ) { + issue( + context, + 'invalid-expectation', + path, + 'Expectation shape is invalid.' + ); + return 0; + } + + validateSafeString( + value['id'], + `${path}.id`, + context, + 'invalid-expectation' + ); + if (typeof value['id'] === 'string') { + if (ids.has(value['id'])) { + issue( + context, + 'duplicate-expectation', + `${path}.id`, + 'Expectation IDs must be unique.' + ); + } + ids.add(value['id']); + } + validateSafeString( + value['operation'], + `${path}.operation`, + context, + 'invalid-expectation' + ); + if (typeof value['origin'] !== 'string' || !origins.has(value['origin'])) { + issue( + context, + 'unknown-origin', + `${path}.origin`, + 'Expectation origin is not declared.' + ); + } + if ( + typeof value['method'] !== 'string' || + !REPLAY_HTTP_METHODS.includes( + value['method'] as (typeof REPLAY_HTTP_METHODS)[number] + ) + ) { + issue( + context, + 'invalid-expectation', + `${path}.method`, + 'Expected an upper-case allowlisted HTTP method.' + ); + } + validatePath(value['path'], `${path}.path`, context); + + if ( + !isRecord(value['request']) || + !hasExactKeys(value['request'], ['query', 'headers', 'cookies', 'body']) + ) { + issue( + context, + 'invalid-request', + `${path}.request`, + 'Request matcher shape is invalid.' + ); + } else { + validateFieldMatchers( + value['request']['query'], + `${path}.request.query`, + context + ); + validateFieldMatchers( + value['request']['headers'], + `${path}.request.headers`, + context, + { lowerCaseKeys: true, allowArrays: true } + ); + validateCookieMatchers( + value['request']['cookies'], + `${path}.request.cookies`, + context + ); + validateRequestBody( + value['request']['body'], + `${path}.request.body`, + context + ); + } + + validateResponse(value['response'], `${path}.response`, context); + + let cardinalityMinimum = 0; + if ( + !isRecord(value['cardinality']) || + !hasExactKeys(value['cardinality'], ['min', 'max']) || + !Number.isInteger(value['cardinality']['min']) || + !Number.isInteger(value['cardinality']['max']) || + (value['cardinality']['min'] as number) < 1 || + (value['cardinality']['max'] as number) < + (value['cardinality']['min'] as number) || + (value['cardinality']['max'] as number) > REPLAY_MAX_REQUESTS + ) { + issue( + context, + 'invalid-cardinality', + `${path}.cardinality`, + 'Cardinality requires 1 <= min <= max <= request limit.' + ); + } else { + cardinalityMinimum = value['cardinality']['min'] as number; + if (value['cardinality']['min'] !== value['cardinality']['max']) { + issue( + context, + 'non-exact-cardinality', + `${path}.cardinality`, + 'Replay v1 requires exact request cardinality.' + ); + } + } + + return cardinalityMinimum; +} + +function validateEndpoint( + value: unknown, + path: string, + context: ValidationContext, + origins: Set +): boolean { + if ( + !isRecord(value) || + !hasExactKeys(value, ['origin', 'path']) || + typeof value['origin'] !== 'string' || + !origins.has(value['origin']) + ) { + issue( + context, + 'unknown-origin', + path, + 'Endpoint must reference a named origin.' + ); + return false; + } + return validatePath(value['path'], `${path}.path`, context); +} + +function validateFixture(value: unknown): ReplayFixtureV1 { + const context: ValidationContext = { + issues: [], + symbols: new Set(), + declaredSymbols: new Set(), + }; + if (!isRecord(value)) { + throw new ReplaySchemaError([ + { + code: 'invalid-fixture', + path: '$', + message: 'Fixture root must be a JSON object.', + }, + ]); + } + + const rootKeys = [ + 'schemaVersion', + 'scenarioId', + 'description', + 'origins', + 'entry', + 'expectedEndpoint', + 'initialState', + 'terminalState', + 'failOnUnexpectedRequest', + 'symbols', + 'phases', + ]; + if (!hasExactKeys(value, rootKeys)) { + issue( + context, + 'invalid-fixture', + '$', + 'Fixture contains unknown root fields.' + ); + } + if (value['schemaVersion'] !== REPLAY_SCHEMA_VERSION) { + issue( + context, + 'unsupported-schema-version', + '$.schemaVersion', + 'Only deterministic replay schema v1 is supported.' + ); + } + validateSafeString( + value['scenarioId'], + '$.scenarioId', + context, + 'invalid-scenario-id' + ); + if ( + value['description'] !== undefined && + typeof value['description'] !== 'string' + ) { + issue( + context, + 'invalid-fixture', + '$.description', + 'Description must be a string.' + ); + } + + const origins = new Set(); + if ( + !isRecord(value['origins']) || + Object.keys(value['origins']).length === 0 + ) { + issue( + context, + 'invalid-origins', + '$.origins', + 'At least one named origin is required.' + ); + } else { + for (const [name, origin] of Object.entries(value['origins'])) { + if (!SAFE_ORIGIN.test(name)) { + issue( + context, + 'invalid-origins', + `$.origins.${name}`, + 'Origin names must be bounded lower-case labels.' + ); + } + origins.add(name); + if ( + !isRecord(origin) || + !hasExactKeys(origin, ['description']) || + (origin['description'] !== undefined && + typeof origin['description'] !== 'string') + ) { + issue( + context, + 'invalid-origins', + `$.origins.${name}`, + 'Origin definition is invalid.' + ); + } + } + } + + validateEndpoint(value['entry'], '$.entry', context, origins); + validateEndpoint( + value['expectedEndpoint'], + '$.expectedEndpoint', + context, + origins + ); + validateSafeString( + value['initialState'], + '$.initialState', + context, + 'invalid-state' + ); + validateSafeString( + value['terminalState'], + '$.terminalState', + context, + 'invalid-state' + ); + if (typeof value['failOnUnexpectedRequest'] !== 'boolean') { + issue( + context, + 'invalid-fixture', + '$.failOnUnexpectedRequest', + 'Unexpected-request policy must be explicit.' + ); + } + + if (!Array.isArray(value['symbols'])) { + issue( + context, + 'invalid-symbol', + '$.symbols', + 'Expected generated symbol declarations.' + ); + } else { + value['symbols'].forEach((symbol, index) => + validateGenerateNode(symbol, `$.symbols[${index}]`, context, true) + ); + } + + if (!Array.isArray(value['phases']) || value['phases'].length === 0) { + issue( + context, + 'invalid-phases', + '$.phases', + 'At least one ordered phase is required.' + ); + } else { + if (value['phases'].length > REPLAY_MAX_PHASES) { + issue( + context, + 'too-many-phases', + '$.phases', + 'Scenario exceeds the 128-phase limit.' + ); + } + let expectationCount = 0; + let minimumRequestCount = 0; + let expectedState = value['initialState']; + const phaseNames = new Set(); + const expectationIds = new Set(); + + value['phases'].forEach((phase, phaseIndex) => { + const phasePath = `$.phases[${phaseIndex}]`; + if ( + !isRecord(phase) || + !hasExactKeys(phase, [ + 'name', + 'state', + 'nextState', + 'mode', + 'barrier', + 'expectations', + ]) + ) { + issue( + context, + 'invalid-phase', + phasePath, + 'Phase shape is invalid.' + ); + return; + } + if ( + validateSafeString( + phase['name'], + `${phasePath}.name`, + context, + 'invalid-phase' + ) + ) { + if (phaseNames.has(phase['name'])) { + issue( + context, + 'duplicate-phase', + `${phasePath}.name`, + 'Phase names must be unique.' + ); + } + phaseNames.add(phase['name']); + } + if (phase['state'] !== expectedState) { + issue( + context, + 'invalid-state-transition', + `${phasePath}.state`, + 'Phases must form a deterministic state chain.' + ); + } + validateSafeString( + phase['state'], + `${phasePath}.state`, + context, + 'invalid-state' + ); + if ( + validateSafeString( + phase['nextState'], + `${phasePath}.nextState`, + context, + 'invalid-state' + ) + ) { + expectedState = phase['nextState']; + } + if (!['ordered', 'unordered'].includes(phase['mode'] as string)) { + issue( + context, + 'invalid-phase', + `${phasePath}.mode`, + 'Phase mode must be ordered or unordered.' + ); + } + if ( + !Array.isArray(phase['expectations']) || + phase['expectations'].length === 0 + ) { + issue( + context, + 'invalid-phase', + `${phasePath}.expectations`, + 'A phase requires at least one expectation.' + ); + return; + } + + expectationCount += phase['expectations'].length; + let phaseMinimum = 0; + const phaseStartingSymbols = new Set(context.symbols); + const phaseGeneratedSymbols = new Set(); + phase['expectations'].forEach((expectation, expectationIndex) => { + const expectationContext = + phase['mode'] === 'unordered' + ? { + issues: context.issues, + symbols: new Set(phaseStartingSymbols), + declaredSymbols: context.declaredSymbols, + } + : context; + const expectationMinimum = validateExpectation( + expectation, + `${phasePath}.expectations[${expectationIndex}]`, + expectationContext, + origins, + expectationIds + ); + phaseMinimum += expectationMinimum; + minimumRequestCount += expectationMinimum; + if (phase['mode'] === 'unordered') { + for (const symbol of expectationContext.symbols) { + if (!phaseStartingSymbols.has(symbol)) { + phaseGeneratedSymbols.add(symbol); + } + } + } + }); + phaseGeneratedSymbols.forEach((symbol) => + context.symbols.add(symbol) + ); + + if (phase['barrier'] !== undefined) { + const barrier = phase['barrier']; + if ( + !isRecord(barrier) || + !hasExactKeys(barrier, ['name', 'releaseWhenMatched']) || + !validateSafeString( + barrier['name'], + `${phasePath}.barrier.name`, + context, + 'invalid-barrier' + ) || + !Number.isInteger(barrier['releaseWhenMatched']) || + (barrier['releaseWhenMatched'] as number) < 1 || + (barrier['releaseWhenMatched'] as number) > phaseMinimum + ) { + issue( + context, + 'invalid-barrier', + `${phasePath}.barrier`, + 'Barrier threshold must be reachable by minimum cardinality.' + ); + } + } + }); + + if (expectationCount > REPLAY_MAX_EXPECTATIONS) { + issue( + context, + 'too-many-expectations', + '$.phases', + 'Scenario exceeds the 512-expectation limit.' + ); + } + if (minimumRequestCount > REPLAY_MAX_REQUESTS) { + issue( + context, + 'too-many-requests', + '$.phases', + 'Scenario minimum cardinality exceeds the 512-request run limit.' + ); + } + if (expectedState !== value['terminalState']) { + issue( + context, + 'invalid-state-transition', + '$.terminalState', + 'The final phase must enter the declared terminal state.' + ); + } + } + + if (context.issues.length > 0) { + throw new ReplaySchemaError(context.issues); + } + return value as unknown as ReplayFixtureV1; +} + +function deepFreeze(value: T, seen = new WeakSet()): T { + if ( + value === null || + (typeof value !== 'object' && typeof value !== 'function') + ) { + return value; + } + const object = value as object; + if (seen.has(object)) { + return value; + } + seen.add(object); + for (const nested of Object.values(object)) { + deepFreeze(nested, seen); + } + return Object.freeze(value); +} + +function serializeFixture(value: unknown): string { + let serialized: string | undefined; + try { + serialized = JSON.stringify(value); + } catch { + throw new ReplaySchemaError([ + { + code: 'invalid-fixture', + path: '$', + message: 'Fixture must be deterministic JSON data.', + }, + ]); + } + if (serialized === undefined) { + throw new ReplaySchemaError([ + { + code: 'invalid-fixture', + path: '$', + message: 'Fixture must be deterministic JSON data.', + }, + ]); + } + return serialized; +} + +export function parseReplayFixture(value: unknown): ReplayFixtureV1 { + if (typeof value === 'string') { + return parseReplayFixtureText(value); + } + const serialized = serializeFixture(value); + if (Buffer.byteLength(serialized) > REPLAY_MAX_FIXTURE_BYTES) { + throw new ReplaySchemaError([ + { + code: 'fixture-too-large', + path: '$', + message: 'Fixture exceeds the 1 MiB limit.', + }, + ]); + } + validateFixture(value); + return deepFreeze(validateFixture(JSON.parse(serialized))); +} + +export function parseReplayFixtureText(text: string): ReplayFixtureV1 { + if (Buffer.byteLength(text) > REPLAY_MAX_FIXTURE_BYTES) { + throw new ReplaySchemaError([ + { + code: 'fixture-too-large', + path: '$', + message: 'Fixture exceeds the 1 MiB limit.', + }, + ]); + } + + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new ReplaySchemaError([ + { + code: 'invalid-json', + path: '$', + message: 'Fixture must contain valid JSON.', + }, + ]); + } + return deepFreeze(validateFixture(value)); +} diff --git a/apps/stalker-mock-server/src/app/replay/replay-symbols.ts b/apps/stalker-mock-server/src/app/replay/replay-symbols.ts new file mode 100644 index 000000000..a6a99cced --- /dev/null +++ b/apps/stalker-mock-server/src/app/replay/replay-symbols.ts @@ -0,0 +1,150 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { + ReplayGenerateNode, + ReplayTemplateString, + ReplayTemplateValue, +} from './replay.types.js'; + +export class ReplaySymbolError extends Error { + constructor(readonly code: 'duplicate-symbol' | 'unknown-symbol') { + super(`Replay symbol error: ${code}.`); + this.name = 'ReplaySymbolError'; + } +} + +function digest(runId: string, symbol: string, valueKind: string): string { + return createHash('sha256') + .update('iptvnator-stalker-replay-v1\0') + .update(runId) + .update('\0') + .update(symbol) + .update('\0') + .update(valueKind) + .digest('hex'); +} + +function generatedValue(node: ReplayGenerateNode, runId: string): string { + const hash = digest(runId, node.symbol, node.valueKind); + switch (node.valueKind) { + case 'mac': + return `02:${hash.slice(0, 2)}:${hash.slice(2, 4)}:${hash.slice(4, 6)}:${hash.slice(6, 8)}:${hash.slice(8, 10)}`; + case 'credential': + return `test-credential-${hash.slice(0, 24)}`; + case 'token': + return `test-token-${hash.slice(0, 40)}`; + case 'random': + return `test-random-${hash.slice(0, 24)}`; + case 'cookie': + return `test-cookie-${hash.slice(0, 32)}`; + case 'correlation': + return `test-correlation-${hash.slice(0, 32)}`; + } +} + +export function createReplayRunId(): string { + return `run-${randomBytes(16).toString('hex')}`; +} + +export class ReplaySymbolTable { + private readonly values = new Map(); + private readonly valueKinds = new Map< + string, + ReplayGenerateNode['valueKind'] + >(); + private readonly clientInputSymbols = new Set(); + + constructor( + private readonly runId: string, + initialSymbols: ReplayGenerateNode[] + ) { + for (const node of initialSymbols) { + this.generate(node); + if (node.valueKind === 'mac' || node.valueKind === 'credential') { + this.clientInputSymbols.add(node.symbol); + } + } + } + + generate(node: ReplayGenerateNode): string { + const existing = this.values.get(node.symbol); + if (existing !== undefined) { + if (this.valueKinds.get(node.symbol) !== node.valueKind) { + throw new ReplaySymbolError('duplicate-symbol'); + } + return existing; + } + const value = generatedValue(node, this.runId); + this.values.set(node.symbol, value); + this.valueKinds.set(node.symbol, node.valueKind); + return value; + } + + resolveString(value: ReplayTemplateString): string { + if (typeof value === 'string') { + return value; + } + switch (value.kind) { + case 'generate': + return this.generate(value); + case 'ref': { + const resolved = this.values.get(value.symbol); + if (resolved === undefined) { + throw new ReplaySymbolError('unknown-symbol'); + } + return resolved; + } + case 'parts': + return value.parts + .map((part) => + part.kind === 'literal' + ? part.value + : this.resolveString(part) + ) + .join(''); + } + } + + resolveValue(value: ReplayTemplateValue): unknown { + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => this.resolveValue(item)); + } + if ( + 'kind' in value && + (value.kind === 'generate' || + value.kind === 'ref' || + value.kind === 'parts') + ) { + return this.resolveString(value as ReplayTemplateString); + } + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + this.resolveValue(item), + ]) + ); + } + + clientInputSnapshot(): Readonly> { + return Object.freeze( + Object.fromEntries( + [...this.values.entries()] + .filter(([symbol]) => this.clientInputSymbols.has(symbol)) + .sort(([left], [right]) => left.localeCompare(right)) + ) + ); + } + + clear(): void { + this.values.clear(); + this.valueKinds.clear(); + this.clientInputSymbols.clear(); + } +} diff --git a/apps/stalker-mock-server/src/app/replay/replay.constants.ts b/apps/stalker-mock-server/src/app/replay/replay.constants.ts new file mode 100644 index 000000000..57b1c94d3 --- /dev/null +++ b/apps/stalker-mock-server/src/app/replay/replay.constants.ts @@ -0,0 +1,38 @@ +export const REPLAY_SCHEMA_VERSION = 1 as const; + +export const REPLAY_MAX_FIXTURE_BYTES = 1024 * 1024; +export const REPLAY_MAX_PHASES = 128; +export const REPLAY_MAX_EXPECTATIONS = 512; +export const REPLAY_MAX_REQUESTS = 512; +export const REPLAY_MAX_GENERATED_BODY_BYTES = 16 * 1024 * 1024; +export const REPLAY_RUN_HARD_LIFETIME_MS = 10 * 60 * 1000; +export const REPLAY_RUN_INACTIVITY_MS = 2 * 60 * 1000; + +export const REPLAY_SYMBOL_VALUE_KINDS = [ + 'mac', + 'credential', + 'token', + 'random', + 'cookie', + 'correlation', +] as const; + +export const REPLAY_HTTP_METHODS = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', +] as const; + +export const REPLAY_COOKIE_ATTRIBUTE_NAMES = [ + 'domain', + 'path', + 'secure', + 'httpOnly', + 'sameSite', + 'maxAge', + 'expires', +] as const; diff --git a/apps/stalker-mock-server/src/app/replay/replay.types.ts b/apps/stalker-mock-server/src/app/replay/replay.types.ts new file mode 100644 index 000000000..ecc824c4c --- /dev/null +++ b/apps/stalker-mock-server/src/app/replay/replay.types.ts @@ -0,0 +1,192 @@ +import { + REPLAY_HTTP_METHODS, + REPLAY_SYMBOL_VALUE_KINDS, +} from './replay.constants.js'; + +export type ReplayHttpMethod = (typeof REPLAY_HTTP_METHODS)[number]; +export type ReplaySymbolValueKind = (typeof REPLAY_SYMBOL_VALUE_KINDS)[number]; + +export type ReplayJsonPrimitive = string | number | boolean | null; + +export type ReplayJsonValue = + | ReplayJsonPrimitive + | ReplayJsonValue[] + | { [key: string]: ReplayJsonValue }; + +export interface ReplayGenerateNode { + kind: 'generate'; + symbol: string; + valueKind: ReplaySymbolValueKind; +} + +export interface ReplayRefNode { + kind: 'ref'; + symbol: string; +} + +export interface ReplayLiteralPart { + kind: 'literal'; + value: string; +} + +export interface ReplayPartsNode { + kind: 'parts'; + parts: Array; +} + +export type ReplayTemplateString = + | string + | ReplayGenerateNode + | ReplayRefNode + | ReplayPartsNode; + +export type ReplayTemplateValue = + | ReplayJsonPrimitive + | ReplayGenerateNode + | ReplayRefNode + | ReplayPartsNode + | ReplayTemplateValue[] + | { [key: string]: ReplayTemplateValue }; + +export type ReplayMatchValue = + | ReplayJsonPrimitive + | ReplayRefNode + | ReplayPartsNode; + +export interface ReplayFieldMatchers { + exact: Record; + present: string[]; + absent: string[]; +} + +export type ReplayRequestBodyMatcher = + | { kind: 'absent' } + | ({ kind: 'json' } & ReplayFieldMatchers) + | ({ kind: 'form' } & ReplayFieldMatchers) + | { kind: 'text'; exact: ReplayTemplateString }; + +export interface ReplayCookieAttributeMatchers { + domain?: ReplayTemplateString; + path?: ReplayTemplateString; + secure?: boolean; + httpOnly?: boolean; + sameSite?: 'strict' | 'lax' | 'none'; + maxAge?: number; + expires?: ReplayTemplateString; +} + +export interface ReplayCookieMatchers extends ReplayFieldMatchers { + attributes: Record; +} + +export interface ReplayRequestMatcher { + query: ReplayFieldMatchers; + headers: ReplayFieldMatchers; + cookies: ReplayCookieMatchers; + body: ReplayRequestBodyMatcher; +} + +export type ReplayResponseBody = + | { kind: 'empty' } + | { kind: 'json'; value: ReplayTemplateValue } + | { + kind: 'jsonp'; + callback: string; + value: ReplayTemplateValue; + } + | { kind: 'text'; value: ReplayTemplateString } + | { kind: 'generated'; byteLength: number; byte: number }; + +export interface ReplayResponseDefinition { + status: number; + headers: Record; + body: ReplayResponseBody; +} + +export interface ReplayCardinality { + min: number; + max: number; +} + +export interface ReplayExpectation { + id: string; + operation: string; + origin: string; + method: ReplayHttpMethod; + path: string; + request: ReplayRequestMatcher; + response: ReplayResponseDefinition; + cardinality: ReplayCardinality; +} + +export interface ReplayBarrier { + name: string; + releaseWhenMatched: number; +} + +export interface ReplayPhase { + name: string; + state: string; + nextState: string; + mode: 'ordered' | 'unordered'; + barrier?: ReplayBarrier; + expectations: ReplayExpectation[]; +} + +export interface ReplayOriginDefinition { + description?: string; +} + +export interface ReplayEndpointDefinition { + origin: string; + path: string; +} + +export interface ReplayFixtureV1 { + schemaVersion: 1; + scenarioId: string; + description?: string; + origins: Record; + entry: ReplayEndpointDefinition; + expectedEndpoint: ReplayEndpointDefinition; + initialState: string; + terminalState: string; + failOnUnexpectedRequest: boolean; + symbols: ReplayGenerateNode[]; + phases: ReplayPhase[]; +} + +export interface ReplayObservedCookie { + value: string; + attributes?: Partial<{ + domain: string; + path: string; + secure: boolean; + httpOnly: boolean; + sameSite: 'strict' | 'lax' | 'none'; + maxAge: number; + expires: string; + }>; +} + +export type ReplayObservedRequestBody = + | { kind: 'absent' } + | { kind: 'json'; value: ReplayJsonValue } + | { kind: 'form'; value: Record } + | { kind: 'text'; value: string }; + +export interface ReplayObservedRequest { + origin: string; + method: ReplayHttpMethod; + path: string; + query: Record; + headers: Record; + cookies: Record; + body: ReplayObservedRequestBody; +} + +export interface ReplayRenderedResponse { + status: number; + headers: Record; + body: Buffer; +} diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index 2e8eed602..9f1447111 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -1,86 +1,9 @@ import http from 'http'; -import express, { Request, Response } from 'express'; -import cors from 'cors'; -import portalRouter from './app/routes/portal.route.js'; -import dispatchPortalAction from './app/routes/dispatch.js'; -import { resetAll } from './app/data-store.js'; import { SCENARIOS } from './app/scenarios.js'; +import { createStalkerMockApp } from './app.js'; const PORT = parseInt(process.env['PORT'] ?? '3210', 10); -const app = express(); - -// --------------------------------------------------------------------------- -// Middleware -// --------------------------------------------------------------------------- -app.use(cors()); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); - -// Log every request -app.use((req, _res, next) => { - const action = req.query['action'] ?? '-'; - const mac = - (req.headers['cookie'] ?? '') - .split(';') - .find((c) => c.trim().startsWith('mac=')) - ?.split('=')[1] - ?.trim() ?? (req.query['macAddress'] as string) ?? 'no-mac'; - console.log( - `[${new Date().toISOString()}] ${req.method} ${req.path} action=${action} mac=${mac}` - ); - next(); -}); - -// --------------------------------------------------------------------------- -// Routes -// --------------------------------------------------------------------------- - -// Stalker portal.php endpoint (direct portal protocol, Electron mode) -app.use('/portal.php', portalRouter); - -/** - * CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape: - * GET /stalker?url=&macAddress=&action=&... - * → { payload: } - * - * The IPTVnator PWA sends Stalker requests to AppConfig.BACKEND_URL/stalker. - * Playwright tests redirect those calls to this endpoint using page.route(), - * so no app code changes are required. - */ -app.get('/stalker', (req: Request, res: Response) => { - const { macAddress, url: _url, ...rest } = req.query as Record; - const mac = macAddress ?? '00:1a:79:00:00:01'; - - // Build a lightweight synthetic request. We need a fresh object with mutable - // `query` and a Cookie header containing the MAC for the handler helpers. - const syntheticReq = { - query: rest, - headers: { cookie: `mac=${mac}` }, - params: {}, - } as unknown as Request; - - // Capture the JSON response and wrap it in the proxy envelope { payload: ... } - let captured: unknown; - const syntheticRes = { - json: (data: unknown) => { - captured = data; - }, - } as unknown as Response; - - dispatchPortalAction(syntheticReq, syntheticRes); - res.json({ payload: captured }); -}); - -// Health check -app.get('/health', (_req: Request, res: Response) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); - -// Reset all in-memory data (useful between Playwright test runs) -app.post('/reset', (_req: Request, res: Response) => { - resetAll(); - res.json({ status: 'reset', timestamp: new Date().toISOString() }); -}); +const app = createStalkerMockApp(); // --------------------------------------------------------------------------- // Start @@ -115,7 +38,9 @@ process.on('unhandledRejection', (reason) => { // When Nx (or any process manager) closes stdin, prevent auto-exit. // The HTTP server handle is what keeps the process alive. process.stdin.resume(); -process.stdin.on('end', () => { /* ignore stdin close */ }); +process.stdin.on('end', () => { + /* ignore stdin close */ +}); server.listen(PORT, () => { const divider = '─'.repeat(62); @@ -135,9 +60,13 @@ server.listen(PORT, () => { ); } console.log(''); - console.log(' Any other MAC generates deterministic unique data from MAC bytes.'); + console.log( + ' Any other MAC generates deterministic unique data from MAC bytes.' + ); console.log(` Utilities:`); console.log(` GET http://localhost:${PORT}/health`); - console.log(` POST http://localhost:${PORT}/reset (clears favorites + cache)`); + console.log( + ` POST http://localhost:${PORT}/reset (clears favorites + cache)` + ); console.log(`${divider}\n`); }); diff --git a/apps/stalker-mock-server/src/test-stubs/faker.ts b/apps/stalker-mock-server/src/test-stubs/faker.ts new file mode 100644 index 000000000..1588029de --- /dev/null +++ b/apps/stalker-mock-server/src/test-stubs/faker.ts @@ -0,0 +1,36 @@ +let sequence = 0; + +function next(label: string): string { + sequence += 1; + return `${label} ${sequence}`; +} + +export const faker = { + seed: (seed: number) => { + sequence = seed; + }, + company: { + name: () => next('Company'), + catchPhrase: () => next('Synthetic programme'), + }, + music: { + genre: () => next('Genre'), + songName: () => next('Song'), + }, + word: { + noun: () => next('Station'), + }, + lorem: { + words: (count: number) => + Array.from({ length: count }, () => next('word')).join(' '), + paragraph: () => next('Synthetic paragraph'), + sentence: () => next('Synthetic sentence'), + }, + person: { + fullName: () => next('Test Person'), + }, + date: { + past: ({ years }: { years: number }) => + new Date(Date.UTC(2026 - years, 0, 1)), + }, +}; diff --git a/apps/stalker-mock-server/tsconfig.spec.json b/apps/stalker-mock-server/tsconfig.spec.json new file mode 100644 index 000000000..40ded9a0f --- /dev/null +++ b/apps/stalker-mock-server/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "rootDir": ".", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json index ef282fec5..9a4748706 100644 --- a/tools/coverage/coverage-policy.json +++ b/tools/coverage/coverage-policy.json @@ -246,6 +246,12 @@ "validationCommand": "pnpm nx test release-tools", "reason": "Node tests validate release-note parsing, rendering and gate policy; the scripts are release tooling, not shipped source." }, + { + "name": "stalker-mock-server", + "root": "apps/stalker-mock-server", + "validationCommand": "pnpm nx test stalker-mock-server", + "reason": "Stateful Stalker replay behavior is validated by focused contract tests; percentage coverage is not a shipped-source quality signal." + }, { "name": "remote-control-web", "root": "apps/remote-control-web", @@ -271,11 +277,6 @@ "root": "apps/xtream-mock-server", "reason": "Validated through Playwright API and user-flow tests unless mock behavior changes directly." }, - { - "name": "stalker-mock-server", - "root": "apps/stalker-mock-server", - "reason": "Validated through Playwright API and user-flow tests unless mock behavior changes directly." - }, { "name": "shared-testing", "root": "libs/shared/testing",