mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
test(stalker): add replay scenario core
This commit is contained in:
1 parent
847d98f035
commit
51d879a734
17 files changed
+3905
-87
No files matched your search
@@ -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',
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user