mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(stalker): harden replay network boundaries
This commit is contained in:
1 parent
f3acc2ce39
commit
375169a69b
11 files changed
+1819
-189
No files matched your search
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable max-lines -- The control-plane security boundary is clearer as explicit wire-level cases. */
|
||||
import {
|
||||
link,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
rm,
|
||||
@@ -428,6 +429,10 @@ describe('replay fixture repository allowlist', () => {
|
||||
outsidePath,
|
||||
path.join(repositoryRoot, 'authentication', 'escape.json')
|
||||
);
|
||||
await link(
|
||||
outsidePath,
|
||||
path.join(repositoryRoot, 'authentication', 'hardlink.json')
|
||||
);
|
||||
const repository = new RepositoryReplayFixtureRepository(
|
||||
repositoryRoot
|
||||
);
|
||||
@@ -448,6 +453,11 @@ describe('replay fixture repository allowlist', () => {
|
||||
).rejects.toMatchObject({
|
||||
code: 'fixture-not-allowed',
|
||||
});
|
||||
await expect(
|
||||
repository.loadFixture('authentication/hardlink')
|
||||
).rejects.toMatchObject({
|
||||
code: 'fixture-not-allowed',
|
||||
});
|
||||
await expect(
|
||||
repository.loadFixture('authentication/missing')
|
||||
).rejects.toMatchObject({
|
||||
@@ -457,6 +467,42 @@ describe('replay fixture repository allowlist', () => {
|
||||
});
|
||||
|
||||
describe('replay control-plane lifecycle and public response', () => {
|
||||
it('waits for an in-flight create and disposes the run before close resolves', async () => {
|
||||
let releaseFixture!: () => void;
|
||||
let markLoadStarted!: () => void;
|
||||
const fixtureReleased = new Promise<void>((resolve) => {
|
||||
releaseFixture = resolve;
|
||||
});
|
||||
const loadStarted = new Promise<void>((resolve) => {
|
||||
markLoadStarted = resolve;
|
||||
});
|
||||
const control = await startReplayControlPlane({
|
||||
capability: TEST_CAPABILITY,
|
||||
repository: {
|
||||
async loadFixture(): Promise<ReplayFixtureV1> {
|
||||
markLoadStarted();
|
||||
await fixtureReleased;
|
||||
return replayFixture();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const creating = authorizedRequest(control.url, 'create', {
|
||||
fixtureId: 'authentication/deferred',
|
||||
});
|
||||
await loadStarted;
|
||||
const closing = control.close();
|
||||
releaseFixture();
|
||||
|
||||
const created = await creating;
|
||||
expect(created.status).toBe(201);
|
||||
const entryUrl = (created.body as { entryUrl: string }).entryUrl;
|
||||
await closing;
|
||||
|
||||
await expect(fetch(entryUrl)).rejects.toThrow();
|
||||
await expect(control.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns only opaque routing and filtered public inputs, then finalizes and disposes the run', async () => {
|
||||
const control = await startReplayControlPlane({
|
||||
capability: TEST_CAPABILITY,
|
||||
|
||||
@@ -38,6 +38,7 @@ export const REPLAY_CONTROL_MAX_BODY_BYTES = 64 * 1024;
|
||||
const CONTROL_ERROR_CODE = {
|
||||
BODY_TOO_LARGE: 'control-body-too-large',
|
||||
BODY_TIMEOUT: 'control-body-timeout',
|
||||
CLOSING: 'control-closing',
|
||||
ENDPOINT_NOT_FOUND: 'endpoint-not-found',
|
||||
FIXTURE_INVALID: 'fixture-invalid',
|
||||
FIXTURE_NOT_ALLOWED: 'fixture-not-allowed',
|
||||
@@ -120,6 +121,40 @@ function isWithinRoot(root: string, candidate: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
interface StableFixtureMetadata {
|
||||
dev: number;
|
||||
ino: number;
|
||||
mode: number;
|
||||
nlink: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
isFile(): boolean;
|
||||
}
|
||||
|
||||
function isAllowedFixtureMetadata(stats: StableFixtureMetadata): boolean {
|
||||
return (
|
||||
stats.isFile() &&
|
||||
stats.nlink === 1 &&
|
||||
stats.size <= REPLAY_MAX_FIXTURE_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
function hasSameFixtureMetadata(
|
||||
left: StableFixtureMetadata,
|
||||
right: StableFixtureMetadata
|
||||
): boolean {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.mode === right.mode &&
|
||||
left.nlink === right.nlink &&
|
||||
left.size === right.size &&
|
||||
left.mtimeMs === right.mtimeMs &&
|
||||
left.ctimeMs === right.ctimeMs
|
||||
);
|
||||
}
|
||||
|
||||
async function readBoundedFixture(handle: FileHandle): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
const readBuffer = Buffer.alloc(64 * 1024);
|
||||
@@ -161,7 +196,7 @@ export class RepositoryReplayFixtureRepository
|
||||
const requestedStats = await lstat(requestedPath);
|
||||
if (
|
||||
requestedStats.isSymbolicLink() ||
|
||||
!requestedStats.isFile()
|
||||
!isAllowedFixtureMetadata(requestedStats)
|
||||
) {
|
||||
throw new ReplayFixtureRepositoryError(
|
||||
'fixture-not-allowed'
|
||||
@@ -176,21 +211,36 @@ export class RepositoryReplayFixtureRepository
|
||||
|
||||
const handle = await open(
|
||||
requestedPath,
|
||||
fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW
|
||||
fsConstants.O_RDONLY |
|
||||
fsConstants.O_NOFOLLOW |
|
||||
fsConstants.O_NONBLOCK
|
||||
);
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
const statsBeforeRead = await handle.stat();
|
||||
if (
|
||||
!stats.isFile() ||
|
||||
stats.size > REPLAY_MAX_FIXTURE_BYTES ||
|
||||
stats.dev !== requestedStats.dev ||
|
||||
stats.ino !== requestedStats.ino
|
||||
!isAllowedFixtureMetadata(statsBeforeRead) ||
|
||||
!hasSameFixtureMetadata(
|
||||
requestedStats,
|
||||
statsBeforeRead
|
||||
)
|
||||
) {
|
||||
throw new ReplayFixtureRepositoryError(
|
||||
'fixture-not-allowed'
|
||||
);
|
||||
}
|
||||
const text = await readBoundedFixture(handle);
|
||||
const statsAfterRead = await handle.stat();
|
||||
if (
|
||||
!isAllowedFixtureMetadata(statsAfterRead) ||
|
||||
!hasSameFixtureMetadata(
|
||||
statsBeforeRead,
|
||||
statsAfterRead
|
||||
)
|
||||
) {
|
||||
throw new ReplayFixtureRepositoryError(
|
||||
'fixture-not-allowed'
|
||||
);
|
||||
}
|
||||
return parseReplayFixtureText(text);
|
||||
} finally {
|
||||
await handle.close();
|
||||
@@ -503,121 +553,139 @@ export async function startReplayControlPlane(
|
||||
}
|
||||
const runs = new Map<string, ReplayServerRun>();
|
||||
let expectedHost = '';
|
||||
let closing = false;
|
||||
const inFlightHandlers = new Set<Promise<void>>();
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
if (request.headers.host !== expectedHost) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.INVALID_HOST,
|
||||
400
|
||||
);
|
||||
}
|
||||
const capabilityHeader =
|
||||
request.headers[REPLAY_CONTROL_CAPABILITY_HEADER];
|
||||
if (
|
||||
Array.isArray(capabilityHeader) ||
|
||||
!capabilityMatches(capabilityHeader, capability)
|
||||
) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.INVALID_CAPABILITY,
|
||||
403
|
||||
);
|
||||
}
|
||||
const actionPath = requestPath(request);
|
||||
if (
|
||||
actionPath !== CREATE_PATH &&
|
||||
actionPath !== FINALIZE_PATH &&
|
||||
actionPath !== DISPOSE_PATH
|
||||
) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.ENDPOINT_NOT_FOUND,
|
||||
404
|
||||
);
|
||||
}
|
||||
if (request.method !== 'POST') {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.METHOD_NOT_ALLOWED,
|
||||
405
|
||||
);
|
||||
}
|
||||
const body = await parseJsonBody(
|
||||
request,
|
||||
requestBodyTimeoutMs
|
||||
);
|
||||
|
||||
if (actionPath === CREATE_PATH) {
|
||||
const fixtureId = readFixtureId(body);
|
||||
let fixture: ReplayFixtureV1;
|
||||
try {
|
||||
fixture = await repository.loadFixture(fixtureId);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ReplayFixtureRepositoryError &&
|
||||
error.code === 'fixture-not-allowed'
|
||||
) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.FIXTURE_NOT_ALLOWED,
|
||||
404
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof ReplayFixtureRepositoryError &&
|
||||
error.code === 'fixture-invalid'
|
||||
) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.FIXTURE_INVALID,
|
||||
422
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const run = await createReplayServerRun(fixture);
|
||||
if (runs.has(run.runId)) {
|
||||
await run.dispose();
|
||||
const server = http.createServer((request, response) => {
|
||||
const handler = (async (): Promise<void> => {
|
||||
try {
|
||||
if (closing) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.INTERNAL,
|
||||
500
|
||||
CONTROL_ERROR_CODE.CLOSING,
|
||||
503
|
||||
);
|
||||
}
|
||||
runs.set(run.runId, run);
|
||||
sendJson(response, 201, {
|
||||
runId: run.runId,
|
||||
entryUrl: run.entryUrl,
|
||||
origins: run.originUrls,
|
||||
inputs: run.generatedInputs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = readRunId(body);
|
||||
const run = runs.get(runId);
|
||||
if (run === undefined) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.RUN_NOT_FOUND,
|
||||
404
|
||||
if (request.headers.host !== expectedHost) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.INVALID_HOST,
|
||||
400
|
||||
);
|
||||
}
|
||||
const capabilityHeader =
|
||||
request.headers[REPLAY_CONTROL_CAPABILITY_HEADER];
|
||||
if (
|
||||
Array.isArray(capabilityHeader) ||
|
||||
!capabilityMatches(capabilityHeader, capability)
|
||||
) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.INVALID_CAPABILITY,
|
||||
403
|
||||
);
|
||||
}
|
||||
const actionPath = requestPath(request);
|
||||
if (
|
||||
actionPath !== CREATE_PATH &&
|
||||
actionPath !== FINALIZE_PATH &&
|
||||
actionPath !== DISPOSE_PATH
|
||||
) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.ENDPOINT_NOT_FOUND,
|
||||
404
|
||||
);
|
||||
}
|
||||
if (request.method !== 'POST') {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.METHOD_NOT_ALLOWED,
|
||||
405
|
||||
);
|
||||
}
|
||||
const body = await parseJsonBody(
|
||||
request,
|
||||
requestBodyTimeoutMs
|
||||
);
|
||||
}
|
||||
if (actionPath === FINALIZE_PATH) {
|
||||
sendJson(response, 200, run.finalize());
|
||||
return;
|
||||
}
|
||||
|
||||
await run.dispose();
|
||||
runs.delete(runId);
|
||||
sendJson(response, 200, { disposed: true });
|
||||
} catch (error) {
|
||||
if (!request.complete) {
|
||||
request.resume();
|
||||
if (actionPath === CREATE_PATH) {
|
||||
const fixtureId = readFixtureId(body);
|
||||
let fixture: ReplayFixtureV1;
|
||||
try {
|
||||
fixture = await repository.loadFixture(fixtureId);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ReplayFixtureRepositoryError &&
|
||||
error.code === 'fixture-not-allowed'
|
||||
) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.FIXTURE_NOT_ALLOWED,
|
||||
404
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof ReplayFixtureRepositoryError &&
|
||||
error.code === 'fixture-invalid'
|
||||
) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.FIXTURE_INVALID,
|
||||
422
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const run = await createReplayServerRun(fixture);
|
||||
if (runs.has(run.runId)) {
|
||||
await run.dispose();
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.INTERNAL,
|
||||
500
|
||||
);
|
||||
}
|
||||
runs.set(run.runId, run);
|
||||
sendJson(response, 201, {
|
||||
runId: run.runId,
|
||||
entryUrl: run.entryUrl,
|
||||
origins: run.originUrls,
|
||||
inputs: run.generatedInputs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = readRunId(body);
|
||||
const run = runs.get(runId);
|
||||
if (run === undefined) {
|
||||
throw new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.RUN_NOT_FOUND,
|
||||
404
|
||||
);
|
||||
}
|
||||
if (actionPath === FINALIZE_PATH) {
|
||||
sendJson(response, 200, run.finalize());
|
||||
return;
|
||||
}
|
||||
|
||||
await run.dispose();
|
||||
runs.delete(runId);
|
||||
sendJson(response, 200, { disposed: true });
|
||||
} catch (error) {
|
||||
if (!request.complete) {
|
||||
request.resume();
|
||||
}
|
||||
if (error instanceof ReplayControlError) {
|
||||
sendControlError(response, error);
|
||||
} else {
|
||||
sendControlError(
|
||||
response,
|
||||
new ReplayControlError(
|
||||
CONTROL_ERROR_CODE.INTERNAL,
|
||||
500
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (error instanceof ReplayControlError) {
|
||||
sendControlError(response, error);
|
||||
} else {
|
||||
sendControlError(
|
||||
response,
|
||||
new ReplayControlError(CONTROL_ERROR_CODE.INTERNAL, 500)
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
inFlightHandlers.add(handler);
|
||||
void handler.then(
|
||||
() => inFlightHandlers.delete(handler),
|
||||
() => inFlightHandlers.delete(handler)
|
||||
);
|
||||
});
|
||||
|
||||
const port = await listen(server);
|
||||
@@ -628,11 +696,13 @@ export async function startReplayControlPlane(
|
||||
url,
|
||||
close: () => {
|
||||
closePromise ??= (async () => {
|
||||
closing = true;
|
||||
await closeServer(server);
|
||||
await Promise.all([...inFlightHandlers]);
|
||||
await Promise.all(
|
||||
[...runs.values()].map((run) => run.dispose())
|
||||
);
|
||||
runs.clear();
|
||||
await closeServer(server);
|
||||
})();
|
||||
return closePromise;
|
||||
},
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
export type ReplayOriginUrlMap = Readonly<Record<string, string>>;
|
||||
|
||||
export class ReplayResponseError extends Error {
|
||||
constructor(readonly code: 'unknown-origin-url') {
|
||||
constructor(
|
||||
readonly code: 'unknown-origin-url' | 'invalid-origin-url'
|
||||
) {
|
||||
super(`Replay response rejected: ${code}.`);
|
||||
this.name = 'ReplayResponseError';
|
||||
}
|
||||
@@ -33,7 +35,60 @@ function renderHeaderValue(
|
||||
if (originUrl === undefined) {
|
||||
throw new ReplayResponseError('unknown-origin-url');
|
||||
}
|
||||
return `${originUrl}${value.path}`;
|
||||
try {
|
||||
const base = new URL(originUrl);
|
||||
const target = new URL(base.href);
|
||||
target.pathname = `${base.pathname}${value.path}`;
|
||||
target.search = '';
|
||||
target.hash = '';
|
||||
if (
|
||||
target.origin !== base.origin ||
|
||||
!target.pathname.startsWith(`${base.pathname}/`)
|
||||
) {
|
||||
throw new Error('named-origin path escaped its run prefix');
|
||||
}
|
||||
if (value.userInfo !== undefined) {
|
||||
target.username = symbols.resolveString(
|
||||
value.userInfo.username
|
||||
);
|
||||
target.password =
|
||||
value.userInfo.password === undefined
|
||||
? ''
|
||||
: symbols.resolveString(value.userInfo.password);
|
||||
}
|
||||
for (const [name, queryValue] of Object.entries(
|
||||
value.query ?? {}
|
||||
)) {
|
||||
const values = Array.isArray(queryValue)
|
||||
? queryValue
|
||||
: [queryValue];
|
||||
for (const item of values) {
|
||||
target.searchParams.append(
|
||||
name,
|
||||
symbols.resolveString(item)
|
||||
);
|
||||
}
|
||||
}
|
||||
return target.href;
|
||||
} catch (error) {
|
||||
if (error instanceof ReplayResponseError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ReplayResponseError('invalid-origin-url');
|
||||
}
|
||||
}
|
||||
|
||||
export function replayRenderedResponseByteLength(
|
||||
response: ReplayRenderedResponse
|
||||
): number {
|
||||
let total = response.body.byteLength;
|
||||
for (const [name, values] of Object.entries(response.headers)) {
|
||||
for (const value of values) {
|
||||
total +=
|
||||
Buffer.byteLength(name) + Buffer.byteLength(value) + 4;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function renderReplayResponse(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* 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_PENDING_BARRIER_BYTES,
|
||||
REPLAY_MAX_REQUESTS,
|
||||
REPLAY_RUN_HARD_LIFETIME_MS,
|
||||
REPLAY_RUN_INACTIVITY_MS,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
import {
|
||||
createReplayRun,
|
||||
ReplayFinalizeResult,
|
||||
ReplayRun,
|
||||
ReplayRunError,
|
||||
} from './replay-run.js';
|
||||
import {
|
||||
@@ -470,6 +472,29 @@ describe('ReplayRun lifecycle and ledger', () => {
|
||||
run.dispose();
|
||||
});
|
||||
|
||||
it('falls through a saturated unordered matcher to an eligible identical matcher', async () => {
|
||||
const first = replayExpectation('first');
|
||||
const second = replayExpectation('second');
|
||||
first.request.query.exact = { action: 'shared' };
|
||||
second.request.query.exact = { action: 'shared' };
|
||||
const run = createReplayRun(replayFixture([first, second]), {
|
||||
runId: 'unordered-saturation',
|
||||
});
|
||||
|
||||
await run.request(observedRequest('shared'));
|
||||
await run.request(observedRequest('shared'));
|
||||
|
||||
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')]),
|
||||
@@ -526,6 +551,154 @@ describe('ReplayRun lifecycle and ledger', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReplayRun scheduled expiry and retained-byte defense', () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('expires and rejects a held barrier after inactivity without another API call', async () => {
|
||||
jest.useFakeTimers({ now: 1_000 });
|
||||
const run = createReplayRun(
|
||||
replayFixture(
|
||||
[replayExpectation('held'), replayExpectation('never')],
|
||||
{
|
||||
barrier: {
|
||||
name: 'inactivity-expiry',
|
||||
releaseWhenMatched: 2,
|
||||
},
|
||||
}
|
||||
),
|
||||
{ runId: 'scheduled-inactivity' }
|
||||
);
|
||||
|
||||
try {
|
||||
const pending = run.request(observedRequest('held'));
|
||||
const outcome = pending.then(
|
||||
() => undefined,
|
||||
(error: ReplayRunError) => error
|
||||
);
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(REPLAY_RUN_INACTIVITY_MS);
|
||||
|
||||
await expect(outcome).resolves.toMatchObject({
|
||||
code: 'run-expired',
|
||||
});
|
||||
expect(run.getLedger().mismatchCounts).toEqual({
|
||||
'run-expired': 1,
|
||||
});
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
run.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('enforces the scheduled hard lifetime while activity keeps resetting inactivity', async () => {
|
||||
jest.useFakeTimers({ now: 10_000 });
|
||||
const repeated = replayExpectation('repeat', { min: 6, max: 6 });
|
||||
const never = replayExpectation('never');
|
||||
const run = createReplayRun(
|
||||
replayFixture([repeated, never], {
|
||||
barrier: {
|
||||
name: 'hard-expiry',
|
||||
releaseWhenMatched: 7,
|
||||
},
|
||||
}),
|
||||
{ runId: 'scheduled-hard-expiry' }
|
||||
);
|
||||
|
||||
try {
|
||||
const pending = [run.request(observedRequest('repeat'))];
|
||||
const settled = pending.map((request) =>
|
||||
request.then(
|
||||
() => ({ status: 'fulfilled' as const }),
|
||||
(reason: ReplayRunError) => ({
|
||||
status: 'rejected' as const,
|
||||
reason,
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
for (let index = 1; index < 6; index += 1) {
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
REPLAY_RUN_INACTIVITY_MS - 1
|
||||
);
|
||||
const request = run.request(observedRequest('repeat'));
|
||||
pending.push(request);
|
||||
settled.push(
|
||||
request.then(
|
||||
() => ({ status: 'fulfilled' as const }),
|
||||
(reason: ReplayRunError) => ({
|
||||
status: 'rejected' as const,
|
||||
reason,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
REPLAY_RUN_HARD_LIFETIME_MS -
|
||||
5 * (REPLAY_RUN_INACTIVITY_MS - 1)
|
||||
);
|
||||
|
||||
expect(await Promise.all(settled)).toEqual(
|
||||
Array.from({ length: 6 }, () =>
|
||||
expect.objectContaining({
|
||||
status: 'rejected',
|
||||
reason: expect.objectContaining({
|
||||
code: 'run-expired',
|
||||
}),
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
run.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a barrier that exceeds the runtime retained-byte cap', async () => {
|
||||
const repeated = replayExpectation('large', {
|
||||
min: 2,
|
||||
max: 2,
|
||||
responseBody: {
|
||||
kind: 'generated',
|
||||
byteLength: REPLAY_MAX_PENDING_BARRIER_BYTES / 2,
|
||||
byte: 97,
|
||||
},
|
||||
});
|
||||
const run = new ReplayRun(
|
||||
replayFixture([repeated], {
|
||||
barrier: {
|
||||
name: 'runtime-cap',
|
||||
releaseWhenMatched: 2,
|
||||
},
|
||||
}),
|
||||
{ runId: 'runtime-barrier-cap' }
|
||||
);
|
||||
const first = run.request(observedRequest('large'));
|
||||
const firstOutcome = first.then(
|
||||
() => undefined,
|
||||
(error: ReplayRunError) => error
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
run.request(observedRequest('large'))
|
||||
).rejects.toMatchObject({
|
||||
code: 'pending-barrier-bytes-exceeded',
|
||||
});
|
||||
expect(run.getLedger().mismatchCounts).toEqual({
|
||||
'pending-barrier-bytes-exceeded': 1,
|
||||
});
|
||||
} finally {
|
||||
run.dispose();
|
||||
}
|
||||
await expect(firstOutcome).resolves.toMatchObject({
|
||||
code: 'run-disposed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReplayRun response rendering', () => {
|
||||
it('reuses one generated symbol when a response expectation repeats', async () => {
|
||||
const expected = replayExpectation('repeat-generated', {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* eslint-disable max-lines -- Replay lifecycle, expiry, matching, and retained-response accounting form one security-sensitive state machine. */
|
||||
import {
|
||||
REPLAY_MAX_PENDING_BARRIER_BYTES,
|
||||
REPLAY_MAX_REQUESTS,
|
||||
REPLAY_RUN_HARD_LIFETIME_MS,
|
||||
REPLAY_RUN_INACTIVITY_MS,
|
||||
@@ -7,7 +9,10 @@ import {
|
||||
matchReplayRequest,
|
||||
ReplayMismatchCode,
|
||||
} from './replay-request-matcher.js';
|
||||
import { renderReplayResponse } from './replay-response.js';
|
||||
import {
|
||||
renderReplayResponse,
|
||||
replayRenderedResponseByteLength,
|
||||
} from './replay-response.js';
|
||||
import { parseReplayFixture } from './replay-schema.js';
|
||||
import { createReplayRunId, ReplaySymbolTable } from './replay-symbols.js';
|
||||
import {
|
||||
@@ -21,6 +26,7 @@ export type ReplayRunErrorCode =
|
||||
| ReplayMismatchCode
|
||||
| 'unexpected-request'
|
||||
| 'cardinality-exceeded'
|
||||
| 'pending-barrier-bytes-exceeded'
|
||||
| 'request-limit-exceeded'
|
||||
| 'run-expired'
|
||||
| 'run-finalized'
|
||||
@@ -64,8 +70,18 @@ export interface CreateReplayRunOptions {
|
||||
runId?: string;
|
||||
now?: () => number;
|
||||
originUrls?: Readonly<Record<string, string>>;
|
||||
onExpired?: () => void;
|
||||
}
|
||||
|
||||
export type ReplayNetworkMismatchCode =
|
||||
| 'invalid-replay-route'
|
||||
| 'invalid-request-method'
|
||||
| 'invalid-request-body'
|
||||
| 'request-body-too-large'
|
||||
| 'request-body-timeout'
|
||||
| 'response-send-failed'
|
||||
| 'replay-internal-error';
|
||||
|
||||
interface PendingBarrierResponse {
|
||||
response: ReplayRenderedResponse;
|
||||
resolve: (response: ReplayRenderedResponse) => void;
|
||||
@@ -86,6 +102,7 @@ export class ReplayRun {
|
||||
private readonly symbols: ReplaySymbolTable;
|
||||
private readonly now: () => number;
|
||||
private readonly originUrls: Readonly<Record<string, string>>;
|
||||
private readonly onExpired?: () => void;
|
||||
private readonly createdAt: number;
|
||||
private lastActivityAt: number;
|
||||
private phaseIndex = 0;
|
||||
@@ -95,6 +112,8 @@ export class ReplayRun {
|
||||
private readonly operationCounts = new Map<string, number>();
|
||||
private readonly mismatchCounts = new Map<string, number>();
|
||||
private readonly pendingBarrierResponses: PendingBarrierResponse[] = [];
|
||||
private pendingBarrierBytes = 0;
|
||||
private expiryTimer?: ReturnType<typeof setTimeout>;
|
||||
private barrierReleased = false;
|
||||
private hadUnexpectedRequest = false;
|
||||
private expectedEndpointReached = false;
|
||||
@@ -109,6 +128,7 @@ export class ReplayRun {
|
||||
this.runId = options.runId ?? createReplayRunId();
|
||||
this.now = options.now ?? Date.now;
|
||||
this.originUrls = options.originUrls ?? {};
|
||||
this.onExpired = options.onExpired;
|
||||
this.createdAt = this.now();
|
||||
this.lastActivityAt = this.createdAt;
|
||||
this.state = fixture.initialState;
|
||||
@@ -118,6 +138,7 @@ export class ReplayRun {
|
||||
this.expectationCounts.set(expectation.id, 0);
|
||||
}
|
||||
}
|
||||
this.scheduleExpiry();
|
||||
}
|
||||
|
||||
getGeneratedInputs(): Readonly<Record<string, string>> {
|
||||
@@ -151,6 +172,7 @@ export class ReplayRun {
|
||||
}
|
||||
this.requestCount += 1;
|
||||
this.lastActivityAt = this.now();
|
||||
this.scheduleExpiry();
|
||||
|
||||
const match = this.findMatch(observed);
|
||||
if (match === undefined) {
|
||||
@@ -185,6 +207,17 @@ export class ReplayRun {
|
||||
const barrier = phase?.barrier;
|
||||
|
||||
if (barrier !== undefined && !this.barrierReleased) {
|
||||
const responseBytes =
|
||||
replayRenderedResponseByteLength(response);
|
||||
if (
|
||||
this.pendingBarrierBytes + responseBytes >
|
||||
REPLAY_MAX_PENDING_BARRIER_BYTES
|
||||
) {
|
||||
return this.rejectUnexpected(
|
||||
'pending-barrier-bytes-exceeded'
|
||||
);
|
||||
}
|
||||
this.pendingBarrierBytes += responseBytes;
|
||||
const held = new Promise<ReplayRenderedResponse>(
|
||||
(resolve, reject) => {
|
||||
this.pendingBarrierResponses.push({
|
||||
@@ -202,6 +235,7 @@ export class ReplayRun {
|
||||
if (phaseMatches >= barrier.releaseWhenMatched) {
|
||||
this.barrierReleased = true;
|
||||
const pending = this.pendingBarrierResponses.splice(0);
|
||||
this.pendingBarrierBytes = 0;
|
||||
pending.forEach((item) => item.resolve(item.response));
|
||||
}
|
||||
this.advanceCompletedPhase();
|
||||
@@ -267,13 +301,43 @@ export class ReplayRun {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
this.clearExpiryTimer();
|
||||
const pending = this.pendingBarrierResponses.splice(0);
|
||||
this.pendingBarrierBytes = 0;
|
||||
pending.forEach((item) =>
|
||||
item.reject(new ReplayRunError('run-disposed'))
|
||||
);
|
||||
this.symbols.clear();
|
||||
}
|
||||
|
||||
recordNetworkFailure(
|
||||
code: ReplayNetworkMismatchCode,
|
||||
requestAlreadyCounted = false
|
||||
): void {
|
||||
if (
|
||||
this.disposed ||
|
||||
this.finalizedResult !== undefined ||
|
||||
this.expired
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.expireIfNeeded();
|
||||
if (this.expired) {
|
||||
return;
|
||||
}
|
||||
if (!requestAlreadyCounted) {
|
||||
if (this.requestCount >= REPLAY_MAX_REQUESTS) {
|
||||
this.recordMismatch('request-limit-exceeded');
|
||||
return;
|
||||
}
|
||||
this.requestCount += 1;
|
||||
}
|
||||
this.lastActivityAt = this.now();
|
||||
this.scheduleExpiry();
|
||||
this.hadUnexpectedRequest = true;
|
||||
this.recordMismatch(code);
|
||||
}
|
||||
|
||||
private assertActive(): void {
|
||||
if (this.disposed) {
|
||||
throw new ReplayRunError('run-disposed');
|
||||
@@ -293,11 +357,54 @@ export class ReplayRun {
|
||||
current - this.lastActivityAt >= REPLAY_RUN_INACTIVITY_MS
|
||||
) {
|
||||
this.expired = true;
|
||||
this.clearExpiryTimer();
|
||||
this.recordMismatch('run-expired');
|
||||
const pending = this.pendingBarrierResponses.splice(0);
|
||||
this.pendingBarrierBytes = 0;
|
||||
pending.forEach((item) =>
|
||||
item.reject(new ReplayRunError('run-expired'))
|
||||
);
|
||||
try {
|
||||
this.onExpired?.();
|
||||
} catch {
|
||||
// Expiry remains authoritative even if listener cleanup fails.
|
||||
}
|
||||
} else {
|
||||
this.scheduleExpiry();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleExpiry(): void {
|
||||
this.clearExpiryTimer();
|
||||
if (
|
||||
this.disposed ||
|
||||
this.expired
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const current = this.now();
|
||||
const delay = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.createdAt +
|
||||
REPLAY_RUN_HARD_LIFETIME_MS -
|
||||
current,
|
||||
this.lastActivityAt +
|
||||
REPLAY_RUN_INACTIVITY_MS -
|
||||
current
|
||||
)
|
||||
);
|
||||
this.expiryTimer = setTimeout(() => {
|
||||
this.expiryTimer = undefined;
|
||||
this.expireIfNeeded();
|
||||
}, delay);
|
||||
this.expiryTimer.unref?.();
|
||||
}
|
||||
|
||||
private clearExpiryTimer(): void {
|
||||
if (this.expiryTimer !== undefined) {
|
||||
clearTimeout(this.expiryTimer);
|
||||
this.expiryTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +414,11 @@ export class ReplayRun {
|
||||
return [];
|
||||
}
|
||||
if (phase.mode === 'unordered') {
|
||||
return phase.expectations;
|
||||
return phase.expectations.filter(
|
||||
(expectation) =>
|
||||
(this.expectationCounts.get(expectation.id) ?? 0) <
|
||||
expectation.cardinality.max
|
||||
);
|
||||
}
|
||||
const next = phase.expectations.find(
|
||||
(expectation) =>
|
||||
@@ -351,7 +462,11 @@ export class ReplayRun {
|
||||
}
|
||||
|
||||
private rejectUnexpected(
|
||||
code: ReplayMismatchCode | 'unexpected-request' | 'cardinality-exceeded'
|
||||
code:
|
||||
| ReplayMismatchCode
|
||||
| 'unexpected-request'
|
||||
| 'cardinality-exceeded'
|
||||
| 'pending-barrier-bytes-exceeded'
|
||||
): never {
|
||||
this.hadUnexpectedRequest = true;
|
||||
this.recordMismatch(code);
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
createReplayServerRun,
|
||||
ReplayServerRun,
|
||||
} from './replay-server.js';
|
||||
import { REPLAY_MAX_REQUEST_BODY_BYTES } from './replay.constants.js';
|
||||
import {
|
||||
REPLAY_MAX_REQUEST_BODY_BYTES,
|
||||
REPLAY_RUN_INACTIVITY_MS,
|
||||
} from './replay.constants.js';
|
||||
import {
|
||||
ReplayExpectation,
|
||||
ReplayFixtureV1,
|
||||
@@ -281,16 +284,23 @@ describe('ReplayServerRun redirects and wire preservation', () => {
|
||||
await Promise.all(activeRuns.splice(0).map((run) => run.dispose()));
|
||||
});
|
||||
|
||||
it('keeps an absolute-path redirect on the same synthetic run origin', async () => {
|
||||
it('resolves a relative redirect under the same synthetic run prefix', async () => {
|
||||
const redirect = expectation('redirect', {
|
||||
path: '/entry',
|
||||
response: {
|
||||
status: 302,
|
||||
headers: { location: ['/landing'] },
|
||||
headers: { location: ['landing?from=relative'] },
|
||||
body: { kind: 'empty' },
|
||||
},
|
||||
});
|
||||
const landing = expectation('landing', { path: '/landing' });
|
||||
const landing = expectation('landing', {
|
||||
path: '/landing',
|
||||
query: {
|
||||
exact: { from: 'relative' },
|
||||
present: [],
|
||||
absent: [],
|
||||
},
|
||||
});
|
||||
const run = await createReplayServerRun(
|
||||
fixture(orderedPhases([redirect, landing]), {
|
||||
entry: { origin: 'portal', path: '/entry' },
|
||||
@@ -302,7 +312,9 @@ describe('ReplayServerRun redirects and wire preservation', () => {
|
||||
|
||||
const first = await fetch(run.entryUrl, { redirect: 'manual' });
|
||||
const location = first.headers.get('location');
|
||||
expect(location).toBe(`${run.originUrls['portal']}/landing`);
|
||||
expect(location).toBe(
|
||||
`${run.originUrls['portal']}/landing?from=relative`
|
||||
);
|
||||
expect(new URL(location!).origin).toBe(
|
||||
new URL(run.entryUrl).origin
|
||||
);
|
||||
@@ -311,7 +323,7 @@ describe('ReplayServerRun redirects and wire preservation', () => {
|
||||
expect(run.finalize().ok).toBe(true);
|
||||
});
|
||||
|
||||
it('renders a typed named-origin redirect as a real cross-origin absolute URL', async () => {
|
||||
it('renders typed user-info and auth query fields on a named-origin redirect', async () => {
|
||||
const redirect = expectation('redirect', {
|
||||
path: '/entry',
|
||||
response: {
|
||||
@@ -322,6 +334,27 @@ describe('ReplayServerRun redirects and wire preservation', () => {
|
||||
kind: 'origin-url',
|
||||
origin: 'auth',
|
||||
path: '/login',
|
||||
userInfo: {
|
||||
username: {
|
||||
kind: 'ref',
|
||||
symbol: 'username',
|
||||
},
|
||||
password: {
|
||||
kind: 'ref',
|
||||
symbol: 'password',
|
||||
},
|
||||
},
|
||||
query: {
|
||||
login: {
|
||||
kind: 'ref',
|
||||
symbol: 'username',
|
||||
},
|
||||
auth: {
|
||||
kind: 'generate',
|
||||
symbol: 'redirect-auth',
|
||||
valueKind: 'token',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -331,12 +364,38 @@ describe('ReplayServerRun redirects and wire preservation', () => {
|
||||
const login = expectation('login', {
|
||||
origin: 'auth',
|
||||
path: '/login',
|
||||
query: {
|
||||
exact: {
|
||||
login: {
|
||||
kind: 'ref',
|
||||
symbol: 'username',
|
||||
},
|
||||
auth: {
|
||||
kind: 'ref',
|
||||
symbol: 'redirect-auth',
|
||||
},
|
||||
},
|
||||
present: [],
|
||||
absent: [],
|
||||
},
|
||||
});
|
||||
const run = await createReplayServerRun(
|
||||
fixture(orderedPhases([redirect, login]), {
|
||||
origins: { portal: {}, auth: {} },
|
||||
entry: { origin: 'portal', path: '/entry' },
|
||||
expectedEndpoint: { origin: 'auth', path: '/login' },
|
||||
symbols: [
|
||||
{
|
||||
kind: 'generate',
|
||||
symbol: 'username',
|
||||
valueKind: 'credential',
|
||||
},
|
||||
{
|
||||
kind: 'generate',
|
||||
symbol: 'password',
|
||||
valueKind: 'credential',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ runId: 'run-cross-origin' }
|
||||
);
|
||||
@@ -344,12 +403,25 @@ describe('ReplayServerRun redirects and wire preservation', () => {
|
||||
|
||||
const first = await fetch(run.entryUrl, { redirect: 'manual' });
|
||||
const location = first.headers.get('location');
|
||||
expect(location).toBe(`${run.originUrls['auth']}/login`);
|
||||
expect(new URL(location!).origin).not.toBe(
|
||||
const target = new URL(location!);
|
||||
expect(`${target.origin}${target.pathname}`).toBe(
|
||||
`${new URL(run.originUrls['auth']!).origin}${
|
||||
new URL(run.originUrls['auth']!).pathname
|
||||
}/login`
|
||||
);
|
||||
expect(target.origin).not.toBe(
|
||||
new URL(run.entryUrl).origin
|
||||
);
|
||||
expect(target.username).toBe(run.generatedInputs['username']);
|
||||
expect(target.password).toBe(run.generatedInputs['password']);
|
||||
expect(target.searchParams.get('login')).toBe(
|
||||
run.generatedInputs['username']
|
||||
);
|
||||
expect(target.searchParams.get('auth')).toMatch(
|
||||
/^test-token-[0-9a-f]+$/
|
||||
);
|
||||
|
||||
await fetch(location!);
|
||||
await rawRequest(location!);
|
||||
expect(run.finalize().ok).toBe(true);
|
||||
});
|
||||
|
||||
@@ -429,6 +501,83 @@ describe('ReplayServerRun redirects and wire preservation', () => {
|
||||
expect(run.finalize().ok).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves constructor and __proto__ fields through query, headers, cookies, and form parsing', async () => {
|
||||
const exactQuery = Object.fromEntries([
|
||||
['constructor', 'query-constructor'],
|
||||
['__proto__', 'query-prototype'],
|
||||
]);
|
||||
const exactHeaders = Object.fromEntries([
|
||||
['constructor', 'header-constructor'],
|
||||
['__proto__', 'header-prototype'],
|
||||
]);
|
||||
const exactCookies = Object.fromEntries([
|
||||
['constructor', 'cookie-constructor'],
|
||||
['__proto__', 'cookie-prototype'],
|
||||
]);
|
||||
const exactForm = Object.fromEntries([
|
||||
['constructor', 'form-constructor'],
|
||||
['__proto__', 'form-prototype'],
|
||||
]);
|
||||
const request = expectation('prototype-fields', {
|
||||
method: 'POST',
|
||||
path: '/request',
|
||||
query: {
|
||||
exact: exactQuery,
|
||||
present: [],
|
||||
absent: [],
|
||||
},
|
||||
headers: {
|
||||
exact: exactHeaders,
|
||||
present: [],
|
||||
absent: [],
|
||||
},
|
||||
cookies: {
|
||||
exact: exactCookies,
|
||||
present: [],
|
||||
absent: [],
|
||||
attributes: {},
|
||||
},
|
||||
body: {
|
||||
kind: 'form',
|
||||
exact: exactForm,
|
||||
present: [],
|
||||
absent: [],
|
||||
},
|
||||
});
|
||||
const run = await createReplayServerRun(
|
||||
fixture(orderedPhases([request]), {
|
||||
entry: { origin: 'portal', path: '/request' },
|
||||
expectedEndpoint: {
|
||||
origin: 'portal',
|
||||
path: '/request',
|
||||
},
|
||||
}),
|
||||
{ runId: 'run-prototype-fields' }
|
||||
);
|
||||
activeRuns.push(run);
|
||||
const headers = Object.fromEntries([
|
||||
['content-type', 'application/x-www-form-urlencoded'],
|
||||
['constructor', 'header-constructor'],
|
||||
['__proto__', 'header-prototype'],
|
||||
[
|
||||
'cookie',
|
||||
'constructor=cookie-constructor; __proto__=cookie-prototype',
|
||||
],
|
||||
]);
|
||||
|
||||
const response = await rawRequest(
|
||||
`${run.entryUrl}?constructor=query-constructor&__proto__=query-prototype`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: 'constructor=form-constructor&__proto__=form-prototype',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(run.finalize().ok).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'json',
|
||||
@@ -520,6 +669,12 @@ describe('ReplayServerRun redirects and wire preservation', () => {
|
||||
error: { code: 'invalid-request-body' },
|
||||
}),
|
||||
});
|
||||
expect(run.finalize()).toMatchObject({
|
||||
ok: false,
|
||||
ledger: {
|
||||
mismatchCounts: { 'invalid-request-body': 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('requires duplicate cookie names to be matched through the raw repeated-header boundary', async () => {
|
||||
@@ -587,6 +742,12 @@ describe('ReplayServerRun redirects and wire preservation', () => {
|
||||
expect(JSON.parse(response.body)).toEqual({
|
||||
error: { code: 'request-body-too-large' },
|
||||
});
|
||||
expect(run.finalize()).toMatchObject({
|
||||
ok: false,
|
||||
ledger: {
|
||||
mismatchCounts: { 'request-body-too-large': 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a chunked body as soon as it crosses the cap without waiting for request end', async () => {
|
||||
@@ -620,6 +781,12 @@ describe('ReplayServerRun redirects and wire preservation', () => {
|
||||
error: { code: 'request-body-too-large' },
|
||||
}),
|
||||
});
|
||||
expect(run.finalize()).toMatchObject({
|
||||
ok: false,
|
||||
ledger: {
|
||||
mismatchCounts: { 'request-body-too-large': 1 },
|
||||
},
|
||||
});
|
||||
stream.request.destroy();
|
||||
});
|
||||
|
||||
@@ -655,11 +822,205 @@ describe('ReplayServerRun redirects and wire preservation', () => {
|
||||
error: { code: 'request-body-timeout' },
|
||||
}),
|
||||
});
|
||||
expect(run.finalize()).toMatchObject({
|
||||
ok: false,
|
||||
ledger: {
|
||||
mismatchCounts: { 'request-body-timeout': 1 },
|
||||
},
|
||||
});
|
||||
stream.request.destroy();
|
||||
});
|
||||
|
||||
it('records a wrong-route request after a successful expected request', async () => {
|
||||
const run = await createReplayServerRun(
|
||||
fixture(
|
||||
orderedPhases([
|
||||
expectation('expected', { path: '/expected' }),
|
||||
]),
|
||||
{
|
||||
entry: { origin: 'portal', path: '/expected' },
|
||||
}
|
||||
),
|
||||
{ runId: 'run-wrong-route' }
|
||||
);
|
||||
activeRuns.push(run);
|
||||
|
||||
await expect(fetch(run.entryUrl)).resolves.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
const outside = new URL(run.entryUrl);
|
||||
outside.pathname = '/outside-replay-prefix';
|
||||
const response = await rawRequest(outside.href);
|
||||
|
||||
expect(response).toMatchObject({
|
||||
status: 404,
|
||||
body: JSON.stringify({
|
||||
error: { code: 'invalid-replay-route' },
|
||||
}),
|
||||
});
|
||||
expect(run.finalize()).toMatchObject({
|
||||
ok: false,
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'unexpected-request' }),
|
||||
]),
|
||||
ledger: {
|
||||
operationCounts: { expected: 1 },
|
||||
mismatchCounts: { 'invalid-replay-route': 1 },
|
||||
terminalState: 'state-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('records a disallowed HTTP method as unexpected network traffic', async () => {
|
||||
const run = await createReplayServerRun(
|
||||
fixture(
|
||||
orderedPhases([
|
||||
expectation('expected', { path: '/expected' }),
|
||||
]),
|
||||
{
|
||||
entry: { origin: 'portal', path: '/expected' },
|
||||
}
|
||||
),
|
||||
{ runId: 'run-invalid-method' }
|
||||
);
|
||||
activeRuns.push(run);
|
||||
|
||||
const response = await rawRequest(run.entryUrl, {
|
||||
method: 'TRACE',
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
status: 405,
|
||||
body: JSON.stringify({
|
||||
error: { code: 'invalid-request-method' },
|
||||
}),
|
||||
});
|
||||
expect(run.finalize()).toMatchObject({
|
||||
ok: false,
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'unexpected-request' }),
|
||||
]),
|
||||
ledger: {
|
||||
mismatchCounts: { 'invalid-request-method': 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('records a deterministic response-send failure without double-counting the request', async () => {
|
||||
const request = expectation('invalid-response', {
|
||||
path: '/request',
|
||||
response: {
|
||||
status: 200,
|
||||
headers: {
|
||||
'x-invalid': ['line-one\r\nline-two'],
|
||||
},
|
||||
body: { kind: 'empty' },
|
||||
},
|
||||
});
|
||||
const run = await createReplayServerRun(
|
||||
fixture(orderedPhases([request]), {
|
||||
entry: { origin: 'portal', path: '/request' },
|
||||
expectedEndpoint: {
|
||||
origin: 'portal',
|
||||
path: '/request',
|
||||
},
|
||||
}),
|
||||
{ runId: 'run-response-send-failure' }
|
||||
);
|
||||
activeRuns.push(run);
|
||||
|
||||
const response = await rawRequest(run.entryUrl);
|
||||
|
||||
expect(response).toMatchObject({
|
||||
status: 500,
|
||||
body: JSON.stringify({
|
||||
error: { code: 'response-send-failed' },
|
||||
}),
|
||||
});
|
||||
expect(run.finalize()).toMatchObject({
|
||||
ok: false,
|
||||
ledger: {
|
||||
operationCounts: { 'invalid-response': 1 },
|
||||
mismatchCounts: { 'response-send-failed': 1 },
|
||||
terminalState: 'state-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReplayServerRun lifecycle', () => {
|
||||
it('expires a held request and closes its listener without another control call', async () => {
|
||||
const realSetImmediate = setImmediate;
|
||||
jest.useFakeTimers({ now: 25_000 });
|
||||
let run: ReplayServerRun | undefined;
|
||||
try {
|
||||
const held = expectation('held', { path: '/held' });
|
||||
const never = expectation('never', { path: '/never' });
|
||||
run = await createReplayServerRun(
|
||||
fixture(
|
||||
[
|
||||
{
|
||||
name: 'expiring-barrier',
|
||||
state: 'start',
|
||||
nextState: 'complete',
|
||||
mode: 'unordered',
|
||||
barrier: {
|
||||
name: 'two-requests',
|
||||
releaseWhenMatched: 2,
|
||||
},
|
||||
expectations: [held, never],
|
||||
},
|
||||
],
|
||||
{
|
||||
entry: { origin: 'portal', path: '/held' },
|
||||
expectedEndpoint: {
|
||||
origin: 'portal',
|
||||
path: '/held',
|
||||
},
|
||||
}
|
||||
),
|
||||
{ runId: 'run-scheduled-expiry' }
|
||||
);
|
||||
const heldResponse = rawRequest(run.entryUrl, {
|
||||
headers: { connection: 'close' },
|
||||
});
|
||||
const heldOutcome = heldResponse.then(
|
||||
(response) => response,
|
||||
(error: Error) => error
|
||||
);
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (run.getLedger().operationCounts['held'] === 1) {
|
||||
break;
|
||||
}
|
||||
await new Promise<void>((resolve) =>
|
||||
realSetImmediate(resolve)
|
||||
);
|
||||
}
|
||||
expect(run.getLedger().operationCounts['held']).toBe(1);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
REPLAY_RUN_INACTIVITY_MS
|
||||
);
|
||||
|
||||
await expect(heldOutcome).resolves.toMatchObject({
|
||||
status: 410,
|
||||
body: JSON.stringify({
|
||||
error: { code: 'run-expired' },
|
||||
}),
|
||||
});
|
||||
await expect(rawRequest(run.entryUrl)).rejects.toThrow();
|
||||
expect(run.finalize()).toMatchObject({
|
||||
ok: false,
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'run-expired' }),
|
||||
]),
|
||||
});
|
||||
} finally {
|
||||
await run?.dispose();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('disposal rejects held barriers and closes every listener', async () => {
|
||||
const held = expectation('held', { path: '/held' });
|
||||
const never = expectation('never', { path: '/never' });
|
||||
|
||||
@@ -4,6 +4,8 @@ import http, {
|
||||
IncomingMessage,
|
||||
Server,
|
||||
ServerResponse,
|
||||
validateHeaderName,
|
||||
validateHeaderValue,
|
||||
} from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import {
|
||||
@@ -43,6 +45,7 @@ const NETWORK_ERROR_CODE = {
|
||||
INVALID_ROUTE: 'invalid-replay-route',
|
||||
REQUEST_BODY_TOO_LARGE: 'request-body-too-large',
|
||||
REQUEST_BODY_TIMEOUT: 'request-body-timeout',
|
||||
RESPONSE_SEND_FAILED: 'response-send-failed',
|
||||
} as const;
|
||||
|
||||
type ReplayNetworkErrorCode =
|
||||
@@ -51,7 +54,8 @@ type ReplayNetworkErrorCode =
|
||||
class ReplayNetworkError extends Error {
|
||||
constructor(
|
||||
readonly code: ReplayNetworkErrorCode,
|
||||
readonly status: number
|
||||
readonly status: number,
|
||||
readonly requestAlreadyCounted = false
|
||||
) {
|
||||
super(`Replay network request rejected: ${code}.`);
|
||||
this.name = 'ReplayNetworkError';
|
||||
@@ -84,14 +88,25 @@ function sendError(
|
||||
status: number,
|
||||
code: string
|
||||
): void {
|
||||
if (response.destroyed) {
|
||||
return;
|
||||
}
|
||||
if (response.headersSent) {
|
||||
response.destroy();
|
||||
return;
|
||||
}
|
||||
const body = Buffer.from(JSON.stringify({ error: { code } }));
|
||||
response.writeHead(status, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(body.byteLength),
|
||||
'cache-control': 'no-store',
|
||||
connection: 'close',
|
||||
});
|
||||
response.end(body);
|
||||
try {
|
||||
response.writeHead(status, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(body.byteLength),
|
||||
'cache-control': 'no-store',
|
||||
connection: 'close',
|
||||
});
|
||||
response.end(body);
|
||||
} catch {
|
||||
response.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function statusForRunError(error: ReplayRunError): number {
|
||||
@@ -121,7 +136,15 @@ function requestPath(
|
||||
if (requestUrl === undefined) {
|
||||
throw new ReplayNetworkError(NETWORK_ERROR_CODE.INVALID_ROUTE, 404);
|
||||
}
|
||||
const url = new URL(requestUrl, 'http://replay.invalid');
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(requestUrl, 'http://replay.invalid');
|
||||
} catch {
|
||||
throw new ReplayNetworkError(
|
||||
NETWORK_ERROR_CODE.INVALID_ROUTE,
|
||||
404
|
||||
);
|
||||
}
|
||||
const path =
|
||||
url.pathname === routePrefix
|
||||
? '/'
|
||||
@@ -135,7 +158,7 @@ function requestPath(
|
||||
}
|
||||
|
||||
function repeatedQuery(url: URL): Record<string, string[]> {
|
||||
const query: Record<string, string[]> = {};
|
||||
const query = Object.create(null) as Record<string, string[]>;
|
||||
for (const [name, value] of url.searchParams) {
|
||||
(query[name] ??= []).push(value);
|
||||
}
|
||||
@@ -143,7 +166,7 @@ function repeatedQuery(url: URL): Record<string, string[]> {
|
||||
}
|
||||
|
||||
function repeatedHeaders(request: IncomingMessage): Record<string, string[]> {
|
||||
const headers: Record<string, string[]> = {};
|
||||
const headers = Object.create(null) as Record<string, string[]>;
|
||||
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
||||
const name = request.rawHeaders[index]?.toLowerCase();
|
||||
const value = request.rawHeaders[index + 1];
|
||||
@@ -162,7 +185,10 @@ function parseCookies(
|
||||
: headers.cookie === undefined
|
||||
? []
|
||||
: [headers.cookie];
|
||||
const cookies: Record<string, ReplayObservedCookie> = {};
|
||||
const cookies = Object.create(null) as Record<
|
||||
string,
|
||||
ReplayObservedCookie
|
||||
>;
|
||||
const duplicateNames = new Set<string>();
|
||||
for (const header of cookieHeaders) {
|
||||
for (const pair of header.split(';')) {
|
||||
@@ -342,7 +368,7 @@ function parseBody(
|
||||
}
|
||||
}
|
||||
if (type === 'application/x-www-form-urlencoded') {
|
||||
const value: Record<string, string> = {};
|
||||
const value = Object.create(null) as Record<string, string>;
|
||||
for (const [name, fieldValue] of new URLSearchParams(text)) {
|
||||
value[name] = fieldValue;
|
||||
}
|
||||
@@ -370,18 +396,107 @@ async function observeRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function staysWithinRunPrefix(target: URL, originUrl: string): boolean {
|
||||
const declared = new URL(originUrl);
|
||||
return (
|
||||
target.origin === declared.origin &&
|
||||
(target.pathname === declared.pathname ||
|
||||
target.pathname.startsWith(`${declared.pathname}/`))
|
||||
);
|
||||
}
|
||||
|
||||
function hasControlOrBackslash(value: string): boolean {
|
||||
return [...value].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return character === '\\' || code <= 31 || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
function hasParentLocationSegment(value: string): boolean {
|
||||
const pathPart = value.split(/[?#]/, 1)[0] ?? '';
|
||||
return pathPart.split('/').some((segment) => {
|
||||
try {
|
||||
return decodeURIComponent(segment).toLowerCase() === '..';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function routeLocation(
|
||||
value: string,
|
||||
currentOriginUrl: string,
|
||||
requestUrl: string | undefined,
|
||||
originUrls: Readonly<Record<string, string>>
|
||||
): string {
|
||||
if (
|
||||
value.trim() !== value ||
|
||||
hasControlOrBackslash(value) ||
|
||||
hasParentLocationSegment(value)
|
||||
) {
|
||||
throw new ReplayNetworkError(
|
||||
NETWORK_ERROR_CODE.RESPONSE_SEND_FAILED,
|
||||
500,
|
||||
true
|
||||
);
|
||||
}
|
||||
try {
|
||||
const currentBase = new URL(currentOriginUrl);
|
||||
let target: URL;
|
||||
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) {
|
||||
target = new URL(value);
|
||||
} else if (value.startsWith('//')) {
|
||||
throw new Error('scheme-relative location');
|
||||
} else if (value.startsWith('/')) {
|
||||
const relative = new URL(value, currentBase.origin);
|
||||
target = new URL(currentBase.href);
|
||||
target.pathname = `${currentBase.pathname}${relative.pathname}`;
|
||||
target.search = relative.search;
|
||||
target.hash = relative.hash;
|
||||
} else {
|
||||
const incoming = new URL(
|
||||
requestUrl ?? `${currentBase.pathname}/`,
|
||||
currentBase.origin
|
||||
);
|
||||
target = new URL(value, incoming);
|
||||
}
|
||||
if (
|
||||
!Object.values(originUrls).some((originUrl) =>
|
||||
staysWithinRunPrefix(target, originUrl)
|
||||
)
|
||||
) {
|
||||
throw new Error('location escaped declared run origins');
|
||||
}
|
||||
return target.href;
|
||||
} catch (error) {
|
||||
if (error instanceof ReplayNetworkError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ReplayNetworkError(
|
||||
NETWORK_ERROR_CODE.RESPONSE_SEND_FAILED,
|
||||
500,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function routedHeaders(
|
||||
response: ReplayRenderedResponse,
|
||||
currentOriginUrl: string
|
||||
currentOriginUrl: string,
|
||||
requestUrl: string | undefined,
|
||||
originUrls: Readonly<Record<string, string>>
|
||||
): Record<string, string[]> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(response.headers).map(([name, values]) => [
|
||||
name,
|
||||
name === 'location'
|
||||
? values.map((value) =>
|
||||
value.startsWith('/')
|
||||
? `${currentOriginUrl}${value}`
|
||||
: value
|
||||
routeLocation(
|
||||
value,
|
||||
currentOriginUrl,
|
||||
requestUrl,
|
||||
originUrls
|
||||
)
|
||||
)
|
||||
: values,
|
||||
])
|
||||
@@ -391,13 +506,35 @@ function routedHeaders(
|
||||
function sendReplayResponse(
|
||||
response: ServerResponse,
|
||||
replay: ReplayRenderedResponse,
|
||||
currentOriginUrl: string
|
||||
currentOriginUrl: string,
|
||||
requestUrl: string | undefined,
|
||||
originUrls: Readonly<Record<string, string>>
|
||||
): void {
|
||||
response.writeHead(
|
||||
replay.status,
|
||||
routedHeaders(replay, currentOriginUrl)
|
||||
);
|
||||
response.end(replay.body);
|
||||
try {
|
||||
const headers = routedHeaders(
|
||||
replay,
|
||||
currentOriginUrl,
|
||||
requestUrl,
|
||||
originUrls
|
||||
);
|
||||
for (const [name, values] of Object.entries(headers)) {
|
||||
validateHeaderName(name);
|
||||
for (const value of values) {
|
||||
validateHeaderValue(name, value);
|
||||
}
|
||||
}
|
||||
response.writeHead(replay.status, headers);
|
||||
response.end(replay.body);
|
||||
} catch (error) {
|
||||
if (error instanceof ReplayNetworkError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ReplayNetworkError(
|
||||
NETWORK_ERROR_CODE.RESPONSE_SEND_FAILED,
|
||||
500,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function listen(server: Server): Promise<number> {
|
||||
@@ -450,8 +587,15 @@ export async function createReplayServerRun(
|
||||
runId
|
||||
)}`;
|
||||
const listeners: ReplayListener[] = [];
|
||||
const originUrls: Record<string, string> = {};
|
||||
const originUrls = Object.create(null) as Record<string, string>;
|
||||
const runtime: { run?: ReplayRun } = {};
|
||||
let closeListenersPromise: Promise<void> | undefined;
|
||||
const closeListeners = (): Promise<void> => {
|
||||
closeListenersPromise ??= Promise.all(
|
||||
listeners.map(({ server }) => closeServer(server))
|
||||
).then(() => undefined);
|
||||
return closeListenersPromise;
|
||||
};
|
||||
|
||||
try {
|
||||
for (const origin of Object.keys(fixture.origins)) {
|
||||
@@ -482,10 +626,16 @@ export async function createReplayServerRun(
|
||||
sendReplayResponse(
|
||||
response,
|
||||
rendered,
|
||||
currentOriginUrl
|
||||
currentOriginUrl,
|
||||
request.url,
|
||||
originUrls
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ReplayNetworkError) {
|
||||
runtime.run?.recordNetworkFailure(
|
||||
error.code,
|
||||
error.requestAlreadyCounted
|
||||
);
|
||||
sendError(response, error.status, error.code);
|
||||
} else if (error instanceof ReplayRunError) {
|
||||
sendError(
|
||||
@@ -494,6 +644,9 @@ export async function createReplayServerRun(
|
||||
error.code
|
||||
);
|
||||
} else {
|
||||
runtime.run?.recordNetworkFailure(
|
||||
NETWORK_ERROR_CODE.INTERNAL
|
||||
);
|
||||
sendError(
|
||||
response,
|
||||
500,
|
||||
@@ -517,6 +670,9 @@ export async function createReplayServerRun(
|
||||
runId,
|
||||
now: options.now,
|
||||
originUrls,
|
||||
onExpired: () => {
|
||||
void closeListeners().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
runtime.run = run;
|
||||
|
||||
@@ -531,9 +687,7 @@ export async function createReplayServerRun(
|
||||
dispose: () => {
|
||||
disposePromise ??= (async () => {
|
||||
run.dispose();
|
||||
await Promise.all(
|
||||
listeners.map(({ server }) => closeServer(server))
|
||||
);
|
||||
await closeListeners();
|
||||
})();
|
||||
return disposePromise;
|
||||
},
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
REPLAY_MAX_EXPECTATIONS,
|
||||
REPLAY_MAX_FIXTURE_BYTES,
|
||||
REPLAY_MAX_GENERATED_BODY_BYTES,
|
||||
REPLAY_MAX_ORIGINS,
|
||||
REPLAY_MAX_PENDING_BARRIER_BYTES,
|
||||
REPLAY_MAX_PHASES,
|
||||
REPLAY_MAX_REQUESTS,
|
||||
REPLAY_RUN_HARD_LIFETIME_MS,
|
||||
@@ -227,6 +229,30 @@ describe('replay fixture schema v1', () => {
|
||||
kind: 'origin-url',
|
||||
origin: 'auth',
|
||||
path: '/login',
|
||||
userInfo: {
|
||||
username: {
|
||||
kind: 'ref',
|
||||
symbol: 'credential',
|
||||
},
|
||||
password: {
|
||||
kind: 'generate',
|
||||
symbol: 'redirect-password',
|
||||
valueKind: 'credential',
|
||||
},
|
||||
},
|
||||
query: {
|
||||
username: {
|
||||
kind: 'ref',
|
||||
symbol: 'credential',
|
||||
},
|
||||
token: [
|
||||
{
|
||||
kind: 'ref',
|
||||
symbol: 'token',
|
||||
},
|
||||
'synthetic-second-value',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -243,10 +269,55 @@ describe('replay fixture schema v1', () => {
|
||||
kind: 'origin-url',
|
||||
origin: 'auth',
|
||||
path: '/login',
|
||||
userInfo: {
|
||||
username: {
|
||||
kind: 'ref',
|
||||
symbol: 'credential',
|
||||
},
|
||||
password: {
|
||||
kind: 'generate',
|
||||
symbol: 'redirect-password',
|
||||
valueKind: 'credential',
|
||||
},
|
||||
},
|
||||
query: {
|
||||
username: {
|
||||
kind: 'ref',
|
||||
symbol: 'credential',
|
||||
},
|
||||
token: [
|
||||
{
|
||||
kind: 'ref',
|
||||
symbol: 'token',
|
||||
},
|
||||
'synthetic-second-value',
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['leading OWS', ' \t//external.invalid/path'],
|
||||
['trailing OWS', '/landing '],
|
||||
['control characters', '/landing\u0000'],
|
||||
['backslash authority', '\\\\external.invalid/path'],
|
||||
['scheme-relative authority', '//external.invalid/path'],
|
||||
['absolute scheme', 'HTTP://external.invalid/path'],
|
||||
['parent dot segment', '../landing'],
|
||||
['encoded parent dot segment', '%2e%2e/landing'],
|
||||
])('rejects a literal Location containing %s', (_label, location) => {
|
||||
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: [location] };
|
||||
|
||||
expectSchemaCode(value, 'invalid-redirect-location');
|
||||
});
|
||||
|
||||
it('rejects unknown or free-form absolute redirect origins', () => {
|
||||
const unknownOrigin = fixture();
|
||||
const unknownResponse = (
|
||||
@@ -459,10 +530,12 @@ describe('replay fixture schema v1', () => {
|
||||
describe('replay fixture limits', () => {
|
||||
it('owns the exact runtime limits', () => {
|
||||
expect(REPLAY_MAX_FIXTURE_BYTES).toBe(1024 * 1024);
|
||||
expect(REPLAY_MAX_ORIGINS).toBe(8);
|
||||
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_MAX_PENDING_BARRIER_BYTES).toBe(32 * 1024 * 1024);
|
||||
expect(REPLAY_RUN_HARD_LIFETIME_MS).toBe(10 * 60 * 1000);
|
||||
expect(REPLAY_RUN_INACTIVITY_MS).toBe(2 * 60 * 1000);
|
||||
});
|
||||
@@ -544,4 +617,107 @@ describe('replay fixture limits', () => {
|
||||
};
|
||||
expectSchemaCode(value, 'generated-body-too-large');
|
||||
});
|
||||
|
||||
it('accepts eight named origins and rejects a ninth', () => {
|
||||
const value = fixture();
|
||||
value['origins'] = Object.fromEntries(
|
||||
Array.from({ length: 8 }, (_, index) => [`origin-${index}`, {}])
|
||||
);
|
||||
value['entry'] = { origin: 'origin-0', path: '/c/' };
|
||||
value['expectedEndpoint'] = {
|
||||
origin: 'origin-0',
|
||||
path: '/portal.php',
|
||||
};
|
||||
for (const phase of value['phases'] as Record<string, unknown>[]) {
|
||||
for (const item of phase['expectations'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>[]) {
|
||||
item['origin'] = 'origin-0';
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => parseReplayFixture(value)).not.toThrow();
|
||||
(value['origins'] as Record<string, unknown>)['origin-8'] = {};
|
||||
expectSchemaCode(value, 'too-many-origins');
|
||||
});
|
||||
|
||||
it('rejects a barrier whose generated responses can retain more than 32 MiB', () => {
|
||||
const value = fixture();
|
||||
const phase = (value['phases'] as Record<string, unknown>[])[0]!;
|
||||
phase['barrier'] = {
|
||||
name: 'oversized-generated-barrier',
|
||||
releaseWhenMatched: 3,
|
||||
};
|
||||
phase['expectations'] = Array.from({ length: 3 }, (_, index) =>
|
||||
expectation(
|
||||
`generated-${index}`,
|
||||
{ kind: 'absent' },
|
||||
{
|
||||
kind: 'generated',
|
||||
byteLength: 16 * 1024 * 1024,
|
||||
byte: index,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expectSchemaCode(value, 'pending-barrier-too-large');
|
||||
});
|
||||
|
||||
it('counts regular response bodies and headers in the pending barrier budget', () => {
|
||||
const value = fixture();
|
||||
const phase = (value['phases'] as Record<string, unknown>[])[0]!;
|
||||
const repeated = expectation(
|
||||
'regular-response',
|
||||
{ kind: 'absent' },
|
||||
{
|
||||
kind: 'text',
|
||||
value: 'x'.repeat(192 * 1024),
|
||||
}
|
||||
);
|
||||
repeated['cardinality'] = { min: 128, max: 128 };
|
||||
(
|
||||
repeated['response'] as Record<string, unknown>
|
||||
)['headers'] = {
|
||||
'x-replay-padding': ['y'.repeat(96 * 1024)],
|
||||
};
|
||||
phase['barrier'] = {
|
||||
name: 'oversized-regular-barrier',
|
||||
releaseWhenMatched: 128,
|
||||
};
|
||||
phase['expectations'] = [repeated];
|
||||
|
||||
expectSchemaCode(value, 'pending-barrier-too-large');
|
||||
});
|
||||
|
||||
it('counts JSON escaping expansion in the pending barrier budget', () => {
|
||||
const value = fixture();
|
||||
const phase = (value['phases'] as Record<string, unknown>[])[0]!;
|
||||
const repeated = expectation(
|
||||
'escaped-json-response',
|
||||
{ kind: 'absent' },
|
||||
{
|
||||
kind: 'json',
|
||||
value: {
|
||||
payload: {
|
||||
kind: 'parts',
|
||||
parts: [
|
||||
{
|
||||
kind: 'literal',
|
||||
value: '\u0001'.repeat(150 * 1024),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
repeated['cardinality'] = { min: 40, max: 40 };
|
||||
phase['barrier'] = {
|
||||
name: 'escaped-json-barrier',
|
||||
releaseWhenMatched: 40,
|
||||
};
|
||||
phase['expectations'] = [repeated];
|
||||
|
||||
expectSchemaCode(value, 'pending-barrier-too-large');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
REPLAY_MAX_EXPECTATIONS,
|
||||
REPLAY_MAX_FIXTURE_BYTES,
|
||||
REPLAY_MAX_GENERATED_BODY_BYTES,
|
||||
REPLAY_MAX_ORIGINS,
|
||||
REPLAY_MAX_PENDING_BARRIER_BYTES,
|
||||
REPLAY_MAX_PHASES,
|
||||
REPLAY_MAX_REQUESTS,
|
||||
REPLAY_SCHEMA_VERSION,
|
||||
@@ -47,6 +49,16 @@ const SAFE_ORIGIN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
const LOWER_CASE_HEADER = /^[a-z0-9!#$%&'*+.^_`|~-]+$/;
|
||||
const JSONP_CALLBACK = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
|
||||
const FREE_FORM_INTERPOLATION = /\$\{|{{|<%|%\{/;
|
||||
const LOCATION_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
||||
const MAX_RENDERED_SYMBOL_BYTES = 128;
|
||||
const MAX_RENDERED_ORIGIN_BASE_BYTES = 256;
|
||||
|
||||
function hasLocationControlOrBackslash(value: string): boolean {
|
||||
return [...value].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return character === '\\' || code <= 31 || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
function issue(
|
||||
context: ValidationContext,
|
||||
@@ -703,6 +715,204 @@ function validateRequestBody(
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasParentLocationSegment(value: string): boolean {
|
||||
const path = value.split(/[?#]/, 1)[0] ?? '';
|
||||
return path.split('/').some((segment) => {
|
||||
try {
|
||||
return decodeURIComponent(segment) === '..';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateLiteralLocation(
|
||||
value: unknown,
|
||||
path: string,
|
||||
context: ValidationContext
|
||||
): value is string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.startsWith(' ') ||
|
||||
value.startsWith('\t') ||
|
||||
value.endsWith(' ') ||
|
||||
value.endsWith('\t') ||
|
||||
hasLocationControlOrBackslash(value) ||
|
||||
LOCATION_SCHEME.test(value) ||
|
||||
value.startsWith('//') ||
|
||||
hasParentLocationSegment(value)
|
||||
) {
|
||||
issue(
|
||||
context,
|
||||
'invalid-redirect-location',
|
||||
path,
|
||||
'Literal redirects must be clean same-origin relative references without parent traversal.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = new URL(value, 'http://replay.invalid/request');
|
||||
if (
|
||||
resolved.origin !== 'http://replay.invalid' ||
|
||||
resolved.username.length > 0 ||
|
||||
resolved.password.length > 0
|
||||
) {
|
||||
throw new Error('cross-origin redirect');
|
||||
}
|
||||
} catch {
|
||||
issue(
|
||||
context,
|
||||
'invalid-redirect-location',
|
||||
path,
|
||||
'Literal redirect is not a safe same-origin relative reference.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateOriginUrlQuery(
|
||||
value: unknown,
|
||||
path: string,
|
||||
context: ValidationContext
|
||||
): boolean {
|
||||
if (!isRecord(value)) {
|
||||
issue(
|
||||
context,
|
||||
'invalid-redirect-location',
|
||||
path,
|
||||
'Named-origin query fields must be an object.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let valid = true;
|
||||
for (const [name, queryValue] of Object.entries(value)) {
|
||||
if (
|
||||
name.length === 0 ||
|
||||
name.length > 256 ||
|
||||
hasLocationControlOrBackslash(name) ||
|
||||
FREE_FORM_INTERPOLATION.test(name)
|
||||
) {
|
||||
issue(
|
||||
context,
|
||||
'invalid-redirect-location',
|
||||
`${path}.${name}`,
|
||||
'Named-origin query keys must be bounded literal names.'
|
||||
);
|
||||
valid = false;
|
||||
continue;
|
||||
}
|
||||
const values = Array.isArray(queryValue) ? queryValue : [queryValue];
|
||||
if (values.length === 0) {
|
||||
issue(
|
||||
context,
|
||||
'invalid-redirect-location',
|
||||
`${path}.${name}`,
|
||||
'Named-origin query arrays cannot be empty.'
|
||||
);
|
||||
valid = false;
|
||||
continue;
|
||||
}
|
||||
values.forEach((item, index) => {
|
||||
valid =
|
||||
validateTemplateString(
|
||||
item,
|
||||
`${path}.${name}[${index}]`,
|
||||
context,
|
||||
true
|
||||
) && valid;
|
||||
});
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
function validateOriginUrlNode(
|
||||
value: Record<string, unknown>,
|
||||
path: string,
|
||||
context: ValidationContext,
|
||||
origins: Set<string>
|
||||
): boolean {
|
||||
if (
|
||||
!hasExactKeys(value, [
|
||||
'kind',
|
||||
'origin',
|
||||
'path',
|
||||
'userInfo',
|
||||
'query',
|
||||
])
|
||||
) {
|
||||
issue(
|
||||
context,
|
||||
'invalid-redirect-location',
|
||||
path,
|
||||
'Named-origin URL node contains unknown fields.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let valid = true;
|
||||
if (
|
||||
typeof value['origin'] !== 'string' ||
|
||||
!origins.has(value['origin'])
|
||||
) {
|
||||
issue(
|
||||
context,
|
||||
'unknown-origin',
|
||||
`${path}.origin`,
|
||||
'Redirect origin must reference a declared named origin.'
|
||||
);
|
||||
valid = false;
|
||||
}
|
||||
valid =
|
||||
validatePath(value['path'], `${path}.path`, context) &&
|
||||
valid;
|
||||
|
||||
if (value['userInfo'] !== undefined) {
|
||||
const userInfo = value['userInfo'];
|
||||
if (
|
||||
!isRecord(userInfo) ||
|
||||
!hasExactKeys(userInfo, ['username', 'password']) ||
|
||||
!Object.prototype.hasOwnProperty.call(userInfo, 'username')
|
||||
) {
|
||||
issue(
|
||||
context,
|
||||
'invalid-redirect-location',
|
||||
`${path}.userInfo`,
|
||||
'Named-origin user info requires a typed username and optional password.'
|
||||
);
|
||||
valid = false;
|
||||
} else {
|
||||
valid =
|
||||
validateTemplateString(
|
||||
userInfo['username'],
|
||||
`${path}.userInfo.username`,
|
||||
context,
|
||||
true
|
||||
) && valid;
|
||||
if (userInfo['password'] !== undefined) {
|
||||
valid =
|
||||
validateTemplateString(
|
||||
userInfo['password'],
|
||||
`${path}.userInfo.password`,
|
||||
context,
|
||||
true
|
||||
) && valid;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value['query'] !== undefined) {
|
||||
valid =
|
||||
validateOriginUrlQuery(
|
||||
value['query'],
|
||||
`${path}.query`,
|
||||
context
|
||||
) && valid;
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
function validateResponseHeaders(
|
||||
value: unknown,
|
||||
path: string,
|
||||
@@ -741,10 +951,7 @@ function validateResponseHeaders(
|
||||
isRecord(headerValue) &&
|
||||
headerValue['kind'] === 'origin-url'
|
||||
) {
|
||||
if (
|
||||
name !== 'location' ||
|
||||
!hasExactKeys(headerValue, ['kind', 'origin', 'path'])
|
||||
) {
|
||||
if (name !== 'location') {
|
||||
issue(
|
||||
context,
|
||||
'invalid-redirect-location',
|
||||
@@ -754,38 +961,17 @@ function validateResponseHeaders(
|
||||
valid = false;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof headerValue['origin'] !== 'string' ||
|
||||
!origins.has(headerValue['origin'])
|
||||
) {
|
||||
issue(
|
||||
context,
|
||||
'unknown-origin',
|
||||
`${headerPath}.origin`,
|
||||
'Redirect origin must reference a declared named origin.'
|
||||
);
|
||||
valid = false;
|
||||
}
|
||||
valid =
|
||||
validatePath(
|
||||
headerValue['path'],
|
||||
`${headerPath}.path`,
|
||||
context
|
||||
validateOriginUrlNode(
|
||||
headerValue,
|
||||
headerPath,
|
||||
context,
|
||||
origins
|
||||
) && valid;
|
||||
return;
|
||||
}
|
||||
if (name === 'location') {
|
||||
if (
|
||||
typeof headerValue !== 'string' ||
|
||||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(headerValue) ||
|
||||
headerValue.startsWith('//')
|
||||
) {
|
||||
issue(
|
||||
context,
|
||||
'invalid-redirect-location',
|
||||
headerPath,
|
||||
'Cross-origin redirects require a typed named-origin URL node.'
|
||||
);
|
||||
if (!validateLiteralLocation(headerValue, headerPath, context)) {
|
||||
valid = false;
|
||||
return;
|
||||
}
|
||||
@@ -939,6 +1125,265 @@ function validateResponse(
|
||||
);
|
||||
}
|
||||
|
||||
function boundedByteSum(left: number, right: number): number {
|
||||
return Math.min(
|
||||
REPLAY_MAX_PENDING_BARRIER_BYTES + 1,
|
||||
left + right
|
||||
);
|
||||
}
|
||||
|
||||
function renderedTemplateStringUpperBound(value: unknown): number {
|
||||
if (typeof value === 'string') {
|
||||
return Buffer.byteLength(value);
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return REPLAY_MAX_PENDING_BARRIER_BYTES + 1;
|
||||
}
|
||||
if (value['kind'] === 'generate' || value['kind'] === 'ref') {
|
||||
return MAX_RENDERED_SYMBOL_BYTES;
|
||||
}
|
||||
if (value['kind'] !== 'parts' || !Array.isArray(value['parts'])) {
|
||||
return REPLAY_MAX_PENDING_BARRIER_BYTES + 1;
|
||||
}
|
||||
return value['parts'].reduce<number>((total, part) => {
|
||||
if (
|
||||
isRecord(part) &&
|
||||
part['kind'] === 'literal' &&
|
||||
typeof part['value'] === 'string'
|
||||
) {
|
||||
return boundedByteSum(total, Buffer.byteLength(part['value']));
|
||||
}
|
||||
return boundedByteSum(total, MAX_RENDERED_SYMBOL_BYTES);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function renderedJsonStringUpperBound(value: unknown): number {
|
||||
if (typeof value === 'string') {
|
||||
return Math.max(0, Buffer.byteLength(JSON.stringify(value)) - 2);
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return REPLAY_MAX_PENDING_BARRIER_BYTES + 1;
|
||||
}
|
||||
if (value['kind'] === 'generate' || value['kind'] === 'ref') {
|
||||
return MAX_RENDERED_SYMBOL_BYTES * 6;
|
||||
}
|
||||
if (value['kind'] !== 'parts' || !Array.isArray(value['parts'])) {
|
||||
return REPLAY_MAX_PENDING_BARRIER_BYTES + 1;
|
||||
}
|
||||
return value['parts'].reduce<number>((total, part) => {
|
||||
if (
|
||||
isRecord(part) &&
|
||||
part['kind'] === 'literal' &&
|
||||
typeof part['value'] === 'string'
|
||||
) {
|
||||
return boundedByteSum(
|
||||
total,
|
||||
Math.max(
|
||||
0,
|
||||
Buffer.byteLength(JSON.stringify(part['value'])) - 2
|
||||
)
|
||||
);
|
||||
}
|
||||
return boundedByteSum(total, MAX_RENDERED_SYMBOL_BYTES * 6);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function renderedTemplateJsonUpperBound(
|
||||
value: unknown,
|
||||
depth = 0
|
||||
): number {
|
||||
if (depth > 64) {
|
||||
return REPLAY_MAX_PENDING_BARRIER_BYTES + 1;
|
||||
}
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'string'
|
||||
) {
|
||||
return Buffer.byteLength(JSON.stringify(value));
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce(
|
||||
(total, item, index) =>
|
||||
boundedByteSum(
|
||||
boundedByteSum(total, index === 0 ? 0 : 1),
|
||||
renderedTemplateJsonUpperBound(item, depth + 1)
|
||||
),
|
||||
2
|
||||
);
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return REPLAY_MAX_PENDING_BARRIER_BYTES + 1;
|
||||
}
|
||||
if (
|
||||
value['kind'] === 'generate' ||
|
||||
value['kind'] === 'ref' ||
|
||||
value['kind'] === 'parts'
|
||||
) {
|
||||
return boundedByteSum(
|
||||
renderedJsonStringUpperBound(value),
|
||||
2
|
||||
);
|
||||
}
|
||||
return Object.entries(value).reduce(
|
||||
(total, [key, item], index) => {
|
||||
const keyBytes = Buffer.byteLength(JSON.stringify(key));
|
||||
const valueBytes = renderedTemplateJsonUpperBound(
|
||||
item,
|
||||
depth + 1
|
||||
);
|
||||
return boundedByteSum(
|
||||
boundedByteSum(
|
||||
boundedByteSum(total, index === 0 ? 0 : 1),
|
||||
keyBytes + 1
|
||||
),
|
||||
valueBytes
|
||||
);
|
||||
},
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
function renderedOriginUrlUpperBound(value: Record<string, unknown>): number {
|
||||
let total = MAX_RENDERED_ORIGIN_BASE_BYTES;
|
||||
total = boundedByteSum(
|
||||
total,
|
||||
typeof value['path'] === 'string'
|
||||
? Buffer.byteLength(value['path']) * 3
|
||||
: REPLAY_MAX_PENDING_BARRIER_BYTES + 1
|
||||
);
|
||||
if (isRecord(value['userInfo'])) {
|
||||
total = boundedByteSum(
|
||||
total,
|
||||
renderedTemplateStringUpperBound(value['userInfo']['username']) *
|
||||
3 +
|
||||
2
|
||||
);
|
||||
if (value['userInfo']['password'] !== undefined) {
|
||||
total = boundedByteSum(
|
||||
total,
|
||||
renderedTemplateStringUpperBound(
|
||||
value['userInfo']['password']
|
||||
) *
|
||||
3 +
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isRecord(value['query'])) {
|
||||
for (const [name, queryValue] of Object.entries(value['query'])) {
|
||||
const values = Array.isArray(queryValue)
|
||||
? queryValue
|
||||
: [queryValue];
|
||||
for (const item of values) {
|
||||
total = boundedByteSum(
|
||||
total,
|
||||
(Buffer.byteLength(name) +
|
||||
renderedTemplateStringUpperBound(item)) *
|
||||
3 +
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function renderedHeaderValueUpperBound(value: unknown): number {
|
||||
return isRecord(value) && value['kind'] === 'origin-url'
|
||||
? renderedOriginUrlUpperBound(value)
|
||||
: renderedTemplateStringUpperBound(value);
|
||||
}
|
||||
|
||||
function renderedResponseUpperBound(expectation: unknown): number {
|
||||
if (!isRecord(expectation) || !isRecord(expectation['response'])) {
|
||||
return REPLAY_MAX_PENDING_BARRIER_BYTES + 1;
|
||||
}
|
||||
const response = expectation['response'];
|
||||
let total = 0;
|
||||
if (isRecord(response['headers'])) {
|
||||
for (const [name, values] of Object.entries(response['headers'])) {
|
||||
if (!Array.isArray(values)) {
|
||||
return REPLAY_MAX_PENDING_BARRIER_BYTES + 1;
|
||||
}
|
||||
for (const value of values) {
|
||||
total = boundedByteSum(
|
||||
total,
|
||||
Buffer.byteLength(name) +
|
||||
renderedHeaderValueUpperBound(value) +
|
||||
4
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const body = response['body'];
|
||||
if (!isRecord(body)) {
|
||||
return REPLAY_MAX_PENDING_BARRIER_BYTES + 1;
|
||||
}
|
||||
switch (body['kind']) {
|
||||
case 'empty':
|
||||
return total;
|
||||
case 'generated':
|
||||
return boundedByteSum(
|
||||
total,
|
||||
typeof body['byteLength'] === 'number'
|
||||
? body['byteLength']
|
||||
: REPLAY_MAX_PENDING_BARRIER_BYTES + 1
|
||||
);
|
||||
case 'text':
|
||||
return boundedByteSum(
|
||||
total,
|
||||
renderedTemplateStringUpperBound(body['value'])
|
||||
);
|
||||
case 'json':
|
||||
return boundedByteSum(
|
||||
total,
|
||||
renderedTemplateJsonUpperBound(body['value'])
|
||||
);
|
||||
case 'jsonp':
|
||||
return boundedByteSum(
|
||||
total,
|
||||
(typeof body['callback'] === 'string'
|
||||
? Buffer.byteLength(body['callback'])
|
||||
: REPLAY_MAX_PENDING_BARRIER_BYTES + 1) +
|
||||
renderedTemplateJsonUpperBound(body['value']) +
|
||||
3
|
||||
);
|
||||
default:
|
||||
return REPLAY_MAX_PENDING_BARRIER_BYTES + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function pendingBarrierUpperBound(
|
||||
expectations: unknown[],
|
||||
releaseWhenMatched: number
|
||||
): number {
|
||||
const responseSizes: number[] = [];
|
||||
for (const expectation of expectations) {
|
||||
if (
|
||||
!isRecord(expectation) ||
|
||||
!isRecord(expectation['cardinality']) ||
|
||||
!Number.isInteger(expectation['cardinality']['max'])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const cardinality = Math.min(
|
||||
expectation['cardinality']['max'] as number,
|
||||
REPLAY_MAX_REQUESTS
|
||||
);
|
||||
const responseSize = renderedResponseUpperBound(expectation);
|
||||
for (let index = 0; index < cardinality; index += 1) {
|
||||
responseSizes.push(responseSize);
|
||||
}
|
||||
}
|
||||
responseSizes.sort((left, right) => right - left);
|
||||
return responseSizes
|
||||
.slice(0, releaseWhenMatched)
|
||||
.reduce(boundedByteSum, 0);
|
||||
}
|
||||
|
||||
function validateExpectation(
|
||||
value: unknown,
|
||||
path: string,
|
||||
@@ -1186,6 +1631,14 @@ function validateFixture(value: unknown): ReplayFixtureV1 {
|
||||
'At least one named origin is required.'
|
||||
);
|
||||
} else {
|
||||
if (Object.keys(value['origins']).length > REPLAY_MAX_ORIGINS) {
|
||||
issue(
|
||||
context,
|
||||
'too-many-origins',
|
||||
'$.origins',
|
||||
'Scenario exceeds the eight-origin listener limit.'
|
||||
);
|
||||
}
|
||||
for (const [name, origin] of Object.entries(value['origins'])) {
|
||||
if (!SAFE_ORIGIN.test(name)) {
|
||||
issue(
|
||||
@@ -1415,6 +1868,24 @@ function validateFixture(value: unknown): ReplayFixtureV1 {
|
||||
'Barrier threshold must be reachable by minimum cardinality.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
isRecord(barrier) &&
|
||||
Number.isInteger(barrier['releaseWhenMatched']) &&
|
||||
(barrier['releaseWhenMatched'] as number) >= 1 &&
|
||||
(barrier['releaseWhenMatched'] as number) <=
|
||||
REPLAY_MAX_REQUESTS &&
|
||||
pendingBarrierUpperBound(
|
||||
phase['expectations'],
|
||||
barrier['releaseWhenMatched'] as number
|
||||
) > REPLAY_MAX_PENDING_BARRIER_BYTES
|
||||
) {
|
||||
issue(
|
||||
context,
|
||||
'pending-barrier-too-large',
|
||||
`${phasePath}.barrier`,
|
||||
'Barrier can retain more than the bounded pending-response budget.'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export const REPLAY_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export const REPLAY_MAX_FIXTURE_BYTES = 1024 * 1024;
|
||||
export const REPLAY_MAX_ORIGINS = 8;
|
||||
export const REPLAY_MAX_PHASES = 128;
|
||||
export const REPLAY_MAX_EXPECTATIONS = 512;
|
||||
export const REPLAY_MAX_REQUESTS = 512;
|
||||
export const REPLAY_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
|
||||
export const REPLAY_MAX_GENERATED_BODY_BYTES = 16 * 1024 * 1024;
|
||||
export const REPLAY_MAX_PENDING_BARRIER_BYTES = 32 * 1024 * 1024;
|
||||
export const REPLAY_RUN_HARD_LIFETIME_MS = 10 * 60 * 1000;
|
||||
export const REPLAY_RUN_INACTIVITY_MS = 2 * 60 * 1000;
|
||||
|
||||
|
||||
@@ -34,10 +34,17 @@ export interface ReplayPartsNode {
|
||||
parts: Array<ReplayLiteralPart | ReplayRefNode>;
|
||||
}
|
||||
|
||||
export interface ReplayOriginUrlUserInfo {
|
||||
username: ReplayTemplateString;
|
||||
password?: ReplayTemplateString;
|
||||
}
|
||||
|
||||
export interface ReplayOriginUrlNode {
|
||||
kind: 'origin-url';
|
||||
origin: string;
|
||||
path: string;
|
||||
userInfo?: ReplayOriginUrlUserInfo;
|
||||
query?: Record<string, ReplayTemplateString | ReplayTemplateString[]>;
|
||||
}
|
||||
|
||||
export type ReplayTemplateString =
|
||||
|
||||
Reference in new issue
Block a user