test(stalker): add replay scenario core

This commit is contained in:
4gray committed 2026-07-27 08:30:33 +02:00
1 parent 847d98f035
commit 51d879a734
17 files changed
+3905 -87

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
export default {
displayName: 'stalker-mock-server',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
],
},
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^@faker-js/faker$': '<rootDir>/src/test-stubs/faker.ts',
},
moduleFileExtensions: ['ts', 'js'],
coverageDirectory: '../../coverage/apps/stalker-mock-server',
};
+7
View File
@@ -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"
}
}
}
}
+132
View File
@@ -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<string, string> } = {}
): Promise<HttpResult> {
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<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve)
);
});
afterEach(async () => {
await new Promise<void>((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',
},
});
});
});
+77
View File
@@ -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<string, string>;
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;
}
@@ -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<ReplayTemplateString | ReplayTemplateString[]>,
observed: Record<string, string[]>,
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<ReplayTemplateValue>,
observed: Record<string, unknown>,
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<ReplayTemplateValue>,
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);
}
@@ -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,
};
}
@@ -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> = {}
): 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();
});
});
@@ -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<string, number>;
mismatchCounts: Record<string, number>;
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<string, number>): Record<string, number> {
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<string, number>();
private readonly operationCounts = new Map<string, number>();
private readonly mismatchCounts = new Map<string, number>();
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<Record<string, string>> {
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<ReplayRenderedResponse> {
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<ReplayRenderedResponse>(
(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<typeof matchReplayRequest>;
}
| undefined {
let firstMismatch:
| {
expectation: ReplayExpectation;
result: ReturnType<typeof matchReplayRequest>;
}
| 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);
}
@@ -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<string, unknown> {
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<string, unknown> {
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: '<html>synthetic</html>' }
),
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<string, unknown>[])[0]![
'expectations'
] as Record<string, unknown>[]
)[0]!['response'] as Record<string, unknown>
)['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<string, unknown>[])[0]![
'expectations'
] as Record<string, unknown>[]
)[0]!['request'] = {
...((
(value['phases'] as Record<string, unknown>[])[0]![
'expectations'
] as Record<string, unknown>[]
)[0]!['request'] as Record<string, unknown>),
body,
};
expectSchemaCode(value, 'invalid-request-body');
});
it('requires lower-case response headers with array values', () => {
const value = fixture();
const response = (
(value['phases'] as Record<string, unknown>[])[0]![
'expectations'
] as Record<string, unknown>[]
)[0]!['response'] as Record<string, unknown>;
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<string, unknown>[])[0]![
'expectations'
] as Record<string, unknown>[]
)[0]!['response'] as Record<string, unknown>;
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<string, unknown>[])[0]![
'expectations'
] as Record<string, unknown>[]
)[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<string, unknown>[])[0]!;
phase['barrier'] = undefined;
phase['expectations'] = [expectation('first'), expectation('second')];
(
(phase['expectations'] as Record<string, unknown>[])[0]![
'cardinality'
] as Record<string, unknown>
)['min'] = 256;
(
(phase['expectations'] as Record<string, unknown>[])[0]![
'cardinality'
] as Record<string, unknown>
)['max'] = 256;
(
(phase['expectations'] as Record<string, unknown>[])[1]![
'cardinality'
] as Record<string, unknown>
)['min'] = 257;
(
(phase['expectations'] as Record<string, unknown>[])[1]![
'cardinality'
] as Record<string, unknown>
)['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<string, unknown>[])[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<string, unknown>[]
)[1]!;
const headers = (consumer['request'] as Record<string, unknown>)[
'headers'
] as Record<string, unknown>;
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<string, unknown>[])[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<string, unknown>[])[0]![
'expectations'
] as Record<string, unknown>[]
)[0]!['response'] as Record<string, unknown>;
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');
});
});
File diff suppressed because it is too large. Load diff
@@ -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<string, string>();
private readonly valueKinds = new Map<
string,
ReplayGenerateNode['valueKind']
>();
private readonly clientInputSymbols = new Set<string>();
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<Record<string, string>> {
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();
}
}
@@ -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;
@@ -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<ReplayLiteralPart | ReplayRefNode>;
}
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<T = ReplayMatchValue> {
exact: Record<string, T>;
present: string[];
absent: string[];
}
export type ReplayRequestBodyMatcher =
| { kind: 'absent' }
| ({ kind: 'json' } & ReplayFieldMatchers<ReplayTemplateValue>)
| ({ kind: 'form' } & ReplayFieldMatchers<ReplayTemplateString>)
| { 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<ReplayTemplateString> {
attributes: Record<string, ReplayCookieAttributeMatchers>;
}
export interface ReplayRequestMatcher {
query: ReplayFieldMatchers<ReplayTemplateString>;
headers: ReplayFieldMatchers<ReplayTemplateString | ReplayTemplateString[]>;
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<string, ReplayTemplateString[]>;
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<string, ReplayOriginDefinition>;
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<string, string> }
| { kind: 'text'; value: string };
export interface ReplayObservedRequest {
origin: string;
method: ReplayHttpMethod;
path: string;
query: Record<string, string[]>;
headers: Record<string, string[]>;
cookies: Record<string, ReplayObservedCookie>;
body: ReplayObservedRequestBody;
}
export interface ReplayRenderedResponse {
status: number;
headers: Record<string, string[]>;
body: Buffer;
}
+11 -82
View File
@@ -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=<portal_url>&macAddress=<mac>&action=<action>&...
* → { payload: <stalker_response> }
*
* 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<string, string>;
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`);
});
@@ -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)),
},
};
@@ -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"]
}
+6 -5
View File
@@ -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",