Files
reverse-engineering-anything/src/browser/PlaywrightScenarioRequest.test.ts
T
morluto ca7c263dd6 fix(browser): admit query expansion and guard saved-value publication (#10052)
fix(browser): admit query expansion and active response saves
2026-11-11 17:15:40 +08:00

2215 lines
71 KiB
TypeScript

import type { Page, Route } from "playwright-core";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js";
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { responsePointerValue } from "./BrowserScenarioResponseValues.js";
import { performPlaywrightScenarioAction } from "./PlaywrightScenarioActions.js";
const ORIGIN = "https://app.example.test";
const scenario = (actions: readonly unknown[], secrets: unknown[] = []) =>
browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: "/opt/chromium/chrome" },
start_url: { url: `${ORIGIN}/` },
actions,
storage: { cookies: [], local_storage: [], session_storage: [] },
secrets,
capture: {},
});
const requestStep = (fields: Record<string, unknown>) => ({
step_id: "create",
action: "request",
method: "POST",
path: "/api/folders",
...fields,
});
const issues = (actions: readonly unknown[], secrets: unknown[] = []) => {
const parsed = browserScenarioSchema.safeParse({
browser: { mode: "launch", executable_path: "/opt/chromium/chrome" },
start_url: { url: `${ORIGIN}/` },
actions,
storage: { cookies: [], local_storage: [], session_storage: [] },
secrets,
capture: {},
});
return parsed.success
? []
: parsed.error.issues.map((issue) => issue.message);
};
describe("request step schema", () => {
it("refuses repeated header names, User-Agent and line breaks in literal values or prefixes", () => {
const literal = (value: string) => ({ source: "literal", value });
expect(
issues([
requestStep({
headers: [
{ name: "X-A", value: literal("one") },
{ name: "x-a", value: literal("two") },
],
}),
]),
).toContainEqual(expect.stringContaining("more than once"));
expect(
issues([
requestStep({
headers: [{ name: "User-Agent", value: literal("agent") }],
}),
]),
).toContainEqual(expect.stringContaining("cannot set"));
expect(
issues([requestStep({ expected_status: [200, 303] })]),
).toContainEqual(expect.stringContaining("3xx"));
for (const header of [
{ name: "X-A", value: literal("a\r\nX-B: b") },
{ name: "X-A", value: literal("a"), prefix: "Bearer\n" },
])
expect(issues([requestStep({ headers: [header] })])).toContainEqual(
expect.stringContaining("line breaks"),
);
});
it("accepts a declared secret as a request header value", () => {
const parsed = scenario(
[
requestStep({
headers: [
{
name: "Authorization",
value: { source: "secret", secret_id: "token" },
prefix: "Bearer ",
},
],
}),
],
[{ secret_id: "token", environment_variable: "APP_TOKEN" }],
);
expect(parsed.actions[0]).toMatchObject({
action: "request",
headers: [
{
name: "Authorization",
value: { source: "secret", secret_id: "token" },
prefix: "Bearer ",
},
],
});
});
it("refuses paths that leave the start origin", () => {
for (const path of [
"//evil.test/x",
"/\\evil.test/x",
"https://evil.test/",
"/\n/[bad]",
])
expect(issues([requestStep({ path })]).length, path).toBeGreaterThan(0);
});
it("refuses a body on GET, a header fetch cannot set and an undeclared secret", () => {
expect(
issues([requestStep({ method: "GET", json: { a: 1 } })]),
).toContainEqual(expect.stringContaining("cannot send a body"));
expect(
issues([
requestStep({
headers: [
{ name: "Cookie", value: { source: "literal", value: "a=b" } },
],
}),
]),
).toContainEqual(expect.stringContaining("cannot set this header"));
expect(
issues([
requestStep({
headers: [
{ name: "X-Key", value: { source: "secret", secret_id: "key" } },
],
}),
]),
).toContainEqual(expect.stringContaining("not declared"));
});
});
interface FakeFetch {
readonly url: string;
readonly init: RequestInit;
}
const fakePage = (options: {
readonly url?: string;
/** The origin the page reports when the request is sent, if it moved after the check. */
readonly sendingOrigin?: string;
readonly cookies?: readonly { name: string; value: string }[];
readonly cookieDelayMs?: number;
readonly evaluateDelayMs?: number;
readonly storage?: Record<string, string>;
}) => {
const url = options.url ?? `${ORIGIN}/dashboard`;
vi.stubGlobal("localStorage", {
getItem: (key: string) => options.storage?.[key] ?? null,
});
vi.stubGlobal("location", {
origin: options.sendingOrigin ?? new URL(url).origin,
});
return {
on: vi.fn(),
off: vi.fn(),
route: async () => {},
unroute: async () => {},
url: () => url,
context: () => ({
cookies: async () => {
if (options.cookieDelayMs !== undefined)
await new Promise((resolve) =>
setTimeout(resolve, options.cookieDelayMs),
);
return options.cookies ?? [];
},
}),
evaluate: async <Arg, Result>(
fn: (arg: Arg) => Result | Promise<Result>,
arg: Arg,
): Promise<Result> => {
if (options.evaluateDelayMs !== undefined)
await new Promise((resolve) =>
setTimeout(resolve, options.evaluateDelayMs),
);
return fn(arg);
},
} as unknown as Page;
};
const stubFetch = (status: number, bodies: readonly string[] = []) => {
const calls: FakeFetch[] = [];
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
calls.push({ url, init });
return new Response(bodies[calls.length - 1] ?? "{}", { status });
});
return calls;
};
const perform = async (
page: Page,
action: BrowserScenarioAction,
secrets: BrowserScenarioSecrets,
signal?: AbortSignal,
) =>
performPlaywrightScenarioAction({
page,
action,
secrets,
startOrigin: ORIGIN,
signal,
});
const resolved = (parsed: ReturnType<typeof scenario>, environment = {}) => {
const secrets = BrowserScenarioSecrets.resolve(parsed, environment);
if (!secrets.ok) throw secrets.error;
return secrets.value;
};
describe("request step execution", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("sends requests with same-origin credentials", async () => {
const calls = stubFetch(200);
const parsed = scenario([requestStep({})]);
await perform(fakePage({}), parsed.actions[0]!, resolved(parsed));
expect(calls).toHaveLength(1);
expect(calls[0]!.init.credentials).toBe("same-origin");
});
it("refuses to run off the start origin, and fails on an unexpected status or a missing cookie", async () => {
stubFetch(403);
const parsed = scenario([requestStep({ expected_status: [200, 201] })]);
const action = parsed.actions[0]!;
await expect(
perform(
fakePage({ url: "https://other.test/" }),
action,
resolved(parsed),
),
).rejects.toThrow("start origin");
await expect(
perform(fakePage({}), action, resolved(parsed)),
).rejects.toThrow("answered 403; expected 200 or 201");
const withCookie = scenario([
requestStep({
headers: [{ name: "X-T", value: { source: "cookie", name: "absent" } }],
}),
]);
await expect(
perform(fakePage({}), withCookie.actions[0]!, resolved(withCookie)),
).rejects.toThrow("cookie absent is not set");
});
it("completes on any status when none is expected and reports fetch errors", async () => {
stubFetch(500);
const parsed = scenario([requestStep({})]);
await expect(
perform(fakePage({}), parsed.actions[0]!, resolved(parsed)),
).resolves.toBeUndefined();
vi.stubGlobal("fetch", async () => {
throw new TypeError("Failed to fetch");
});
await expect(
perform(fakePage({}), parsed.actions[0]!, resolved(parsed)),
).rejects.toThrow("Failed to fetch");
});
});
describe("request dispatch boundary", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("sends nothing when browser evaluation starts after the absolute deadline", async () => {
const calls = stubFetch(200);
const parsed = scenario([requestStep({ timeout_ms: 5 })]);
await expect(
perform(
fakePage({ evaluateDelayMs: 30 }),
parsed.actions[0]!,
resolved(parsed),
),
).rejects.toThrow("ran out of time");
expect(calls).toEqual([]);
});
it("guards dispatch after cancellation while evaluation was queued, and removes its route", async () => {
const parsed = scenario([requestStep({})]);
const controller = new AbortController();
const page = fakePage({});
let gate: ((route: Route) => Promise<void>) | undefined;
vi.spyOn(page, "route").mockImplementation(async (_match, handler) => {
gate = handler as typeof gate;
return {
dispose: async () => undefined,
[Symbol.asyncDispose]: async () => undefined,
};
});
const unroute = vi.spyOn(page, "unroute");
const sent = vi.fn();
vi.spyOn(page, "evaluate").mockImplementation(async (fn, arg) => {
controller.abort("abandoned while queued");
return (fn as (arg: unknown) => unknown)(arg);
});
vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
if (gate === undefined) throw new Error("Missing dispatch owner");
await gate({
request: () => ({
method: () => init.method,
postData: () => init.body ?? null,
}),
fallback: sent,
abort: async () => {
throw new Error("aborted at dispatch");
},
} as unknown as Route);
return new Response("{}", { status: 200 });
});
await expect(
performPlaywrightScenarioAction({
page,
action: parsed.actions[0]!,
secrets: resolved(parsed),
startOrigin: ORIGIN,
signal: controller.signal,
}),
).rejects.toThrow("aborted at dispatch");
expect(sent).not.toHaveBeenCalled();
expect(unroute).toHaveBeenCalledOnce();
});
});
describe("request route cleanup", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("preserves the primary request failure when removing its route also fails", async () => {
const parsed = scenario([requestStep({})]);
const page = fakePage({});
const primary = new Error("page.evaluate: Target page has been closed");
const cleanup = new Error("page.unroute: Target page has been closed");
vi.spyOn(page, "evaluate").mockRejectedValue(primary);
const unroute = vi.spyOn(page, "unroute").mockRejectedValue(cleanup);
const failure = await perform(
page,
parsed.actions[0]!,
resolved(parsed),
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AggregateError);
expect((failure as AggregateError).cause).toBe(primary);
expect((failure as AggregateError).errors).toEqual([primary, cleanup]);
expect(unroute).toHaveBeenCalledOnce();
});
it("preserves an unsuccessful page response when removing its route fails", async () => {
stubFetch(503);
const parsed = scenario([requestStep({ expected_status: [200] })]);
const page = fakePage({});
const cleanup = new Error("route cleanup failed");
vi.spyOn(page, "unroute").mockRejectedValue(cleanup);
const failure = await perform(
page,
parsed.actions[0]!,
resolved(parsed),
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AggregateError);
expect((failure as AggregateError).errors[0].message).toContain(
"answered 503",
);
expect((failure as AggregateError).errors[1]).toBe(cleanup);
expect((failure as AggregateError).cause).toBe(
(failure as AggregateError).errors[0],
);
});
it("publishes no saved values if route cleanup fails after a successful response", async () => {
stubFetch(200, [JSON.stringify({ id: "created" })]);
const parsed = scenario([
requestStep({ save: [{ name: "id", json_pointer: "/id" }] }),
]);
const page = fakePage({});
const secrets = resolved(parsed);
const save = vi.spyOn(secrets, "save");
const ready = vi.spyOn(secrets, "markResponseSecretsReady");
const cleanup = new Error("route cleanup failed");
vi.spyOn(page, "unroute").mockRejectedValue(cleanup);
await expect(perform(page, parsed.actions[0]!, secrets)).rejects.toBe(
cleanup,
);
expect(save).not.toHaveBeenCalled();
expect(ready).not.toHaveBeenCalled();
});
it.each([
{ status: 200, end: "cancellation" },
{ status: 503, end: "cancellation" },
{ status: 200, end: "deadline" },
{ status: 503, end: "deadline" },
])(
"publishes nothing after $end during final route cleanup (status $status)",
async ({ status, end }) => {
stubFetch(status, ['{"id":"late-object"}']);
const parsed = scenario([
requestStep({
expected_status: [200],
timeout_ms: 5000,
save: [{ name: "id", json_pointer: "/id" }],
}),
]);
const page = fakePage({});
const secrets = resolved(parsed);
const cancel = new AbortController();
const began = Date.now();
const unroute = vi.spyOn(page, "unroute").mockImplementation(async () => {
if (end === "cancellation")
cancel.abort("cancelled during final route cleanup");
else {
const now = vi.spyOn(Date, "now").mockReturnValue(began + 5001);
onTestFinished(() => now.mockRestore());
}
});
await expect(
perform(page, parsed.actions[0]!, secrets, cancel.signal),
).rejects.toThrow(status === 503 ? "answered 503" : "ended before");
expect(() => secrets.savedValue("id")).toThrow("No earlier step saved");
expect(unroute).toHaveBeenCalledOnce();
},
);
});
describe("request page boundaries", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("refuses foreign-origin storage before reading its value", async () => {
const calls = stubFetch(200);
const read = vi.fn();
const parsed = scenario([
requestStep({
headers: [
{
name: "Authorization",
value: { source: "local_storage", key: "token" },
},
],
}),
]);
const page = fakePage({ sendingOrigin: "https://other.test" });
vi.stubGlobal("localStorage", { getItem: read });
await expect(
perform(page, parsed.actions[0]!, resolved(parsed)),
).rejects.toThrow("local storage");
expect(read).not.toHaveBeenCalled();
expect(calls).toEqual([]);
});
it("drains response chunks and releases the reader without materializing the body", async () => {
const read = vi
.fn()
.mockResolvedValueOnce({ done: false, value: new Uint8Array(8) })
.mockResolvedValueOnce({ done: true });
const releaseLock = vi.fn();
const arrayBuffer = vi.fn();
vi.stubGlobal("fetch", async () => ({
status: 200,
type: "basic",
body: { getReader: () => ({ read, releaseLock }) },
arrayBuffer,
}));
const parsed = scenario([requestStep({})]);
await perform(fakePage({}), parsed.actions[0]!, resolved(parsed));
expect(read).toHaveBeenCalledTimes(2);
expect(releaseLock).toHaveBeenCalledOnce();
expect(arrayBuffer).not.toHaveBeenCalled();
});
});
describe("request step confinement", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("follows no redirect and sends nothing once the page has left the start origin", async () => {
const calls: FakeFetch[] = [];
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
calls.push({ url, init });
return {
type: "opaqueredirect",
status: 0,
arrayBuffer: async () => new ArrayBuffer(0),
};
});
const parsed = scenario([requestStep({})]);
const onOpaqueRedirect = vi.fn();
await expect(
performPlaywrightScenarioAction({
page: fakePage({}),
action: parsed.actions[0]!,
secrets: resolved(parsed),
startOrigin: ORIGIN,
onOpaqueRedirect,
}),
).rejects.toThrow("request steps do not follow");
expect(onOpaqueRedirect).toHaveBeenCalledOnce();
expect(calls[0]!.init.redirect).toBe("manual");
await expect(
perform(
fakePage({ sendingOrigin: "https://other.test" }),
parsed.actions[0]!,
resolved(parsed),
),
).rejects.toThrow("the page moved");
expect(calls).toHaveLength(1);
});
it("sends nothing when its time runs out while header values are read, or the step is cancelled", async () => {
const calls = stubFetch(200);
const parsed = scenario([
requestStep({
timeout_ms: 5,
headers: [{ name: "X-T", value: { source: "cookie", name: "t" } }],
}),
]);
const cookies = [{ name: "t", value: "token-value-1" }];
await expect(
perform(
fakePage({ cookies, cookieDelayMs: 30 }),
parsed.actions[0]!,
resolved(parsed),
),
).rejects.toThrow("ran out of time");
const cancelled = new AbortController();
cancelled.abort();
await expect(
performPlaywrightScenarioAction({
page: fakePage({ cookies }),
action: scenario([requestStep({})]).actions[0]!,
secrets: resolved(parsed),
startOrigin: ORIGIN,
signal: cancelled.signal,
}),
).rejects.toThrow("ran out of time");
expect(calls).toEqual([]);
});
it("appends query entries after the path's literal query without re-encoding it", async () => {
const calls = stubFetch(200);
const parsed = scenario([
requestStep({
path: "/api/items?flag&a=b%20c&s=x;y",
query: [
{ name: "q", value: { source: "literal", value: "a b私😀!~'" } },
],
}),
]);
await perform(fakePage({}), parsed.actions[0]!, resolved(parsed));
expect(calls[0]!.url).toBe(
`${ORIGIN}/api/items?flag&a=b%20c&s=x;y&q=a%20b%E7%A7%81%F0%9F%98%80!~'`,
);
for (const [path, sent] of [
["/api/items?", "/api/items?q=1"],
["/api/items#", "/api/items?q=1"],
["/api/items?a=1&", "/api/items?a=1&q=1"],
["/api/items?a=1#top", "/api/items?a=1&q=1"],
] as const) {
const edge = scenario([
requestStep({
path,
query: [{ name: "q", value: { source: "literal", value: "1" } }],
}),
]);
await perform(fakePage({}), edge.actions[0]!, resolved(edge));
expect(calls.at(-1)!.url, path).toBe(`${ORIGIN}${sent}`);
}
});
});
describe("request page value admission", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("preserves exact local storage pointer values and refuses lossy numbers before dispatch", async () => {
const calls = stubFetch(200);
const parsed = scenario([
requestStep({
headers: [
{
name: "X-Account",
value: {
source: "local_storage",
key: "auth",
json_pointer: "/id",
},
},
],
}),
]);
for (const literal of ["9007199254740993", "1e400", "-0"]) {
await expect(
perform(
fakePage({ storage: { auth: `{"id":${literal}}` } }),
parsed.actions[0]!,
resolved(parsed),
),
).rejects.toThrow("cannot be read exactly");
}
expect(calls).toEqual([]);
for (const [literal, expected] of [
["9007199254740991", "9007199254740991"],
['"9007199254740993"', "9007199254740993"],
["42", "42"],
]) {
await perform(
fakePage({
storage: { auth: `{"id":${literal},"unselected":9007199254740993}` },
}),
parsed.actions[0]!,
resolved(parsed),
);
expect(calls.at(-1)!.init.headers).toEqual([["X-Account", expected]]);
}
});
it("refuses page values a header cannot carry, and stored values it cannot read", async () => {
const calls = stubFetch(200);
const header = (value: Record<string, unknown>) =>
scenario([requestStep({ headers: [{ name: "X-T", value }] })]);
const cookie = header({ source: "cookie", name: "t" });
await expect(
perform(
fakePage({ cookies: [{ name: "t", value: "a\r\nX-B: b" }] }),
cookie.actions[0]!,
resolved(cookie),
),
).rejects.toThrow("cannot carry");
for (const [storage, pointer, message] of [
[{}, undefined, "local storage has no auth"],
[{ auth: "not json" }, "/token", "is not JSON"],
[{ auth: '{"other":1}' }, "/token", "no string or number at /token"],
] as const) {
const stored = header({
source: "local_storage",
key: "auth",
...(pointer === undefined ? {} : { json_pointer: pointer }),
});
await expect(
perform(fakePage({ storage }), stored.actions[0]!, resolved(stored)),
).rejects.toThrow(message);
}
expect(calls).toEqual([]);
const numeric = header({
source: "local_storage",
key: "auth",
json_pointer: "/org",
});
await perform(
fakePage({ storage: { auth: '{"org":42}' } }),
numeric.actions[0]!,
resolved(numeric),
);
expect(calls[0]!.init.headers).toEqual([["X-T", "42"]]);
});
});
describe("page value redaction", () => {
const secrets = () => resolved(scenario([requestStep({})]));
it("keeps every admitted value redacted, even under a repeated or colliding label", () => {
const held = secrets();
held.admitPageValue("s.x-a", "first-secret-AAAA");
held.admitPageValue("s.x-a", "second-secret-BBBB");
held.admitPageValue("a.b.c", "third-secret-CCCC");
held.admitPageValue("a.b.c", "fourth-secret-DDDD");
const text = held.redact(
"first-secret-AAAA second-secret-BBBB third-secret-CCCC fourth-secret-DDDD",
);
for (const value of ["AAAA", "BBBB", "CCCC", "DDDD"])
expect(text).not.toContain(value);
});
it("keeps a value's first marker when later steps read it again", () => {
const held = secrets();
held.admitPageValue("create-1.X-CSRFToken", "csrf-token-value");
held.admitPageValue("create-2.X-CSRFToken", "csrf-token-value");
expect(held.redact("csrf-token-value")).toBe(
"[REDACTED:page:create-1.X-CSRFToken]",
);
});
it("redacts the value fetch sends when the stored one has surrounding spaces", () => {
const held = secrets();
held.admitPageValue("s.X-Tok", " padded-token-4f9e2a\t");
expect(held.redact("X-Tok: padded-token-4f9e2a")).not.toContain(
"padded-token-4f9e2a",
);
});
it("does not redact values too short to tell from ids and ports", () => {
const held = secrets();
held.admitPageValue("s.X-Org-Id", "1");
held.admitPageValue("s.X-Port", "3000");
const text = "http://127.0.0.1:3000/api/items/12?page=1";
expect(held.redact(text)).toBe(text);
});
});
const savedValue = (name: string) => ({ source: "saved", name });
const saveIdStep = (stepId: string, name: string) =>
requestStep({
step_id: stepId,
save: [{ name, json_pointer: "/id" }],
});
const clickSavingItem = (name: string) => ({
step_id: "save-item",
action: "click",
locator: { kind: "role", role: "button", name: "Save" },
save_response: {
method: "POST",
path: "/api/items",
save: [{ name, json_pointer: "/id" }],
},
});
const gotoStep = (url: string) => ({
step_id: "open",
action: "goto",
destination: { url },
wait_until: "load",
});
describe("saved value schema", () => {
it("refuses a saved value no earlier step saves, wherever a step sends it", () => {
const sendsItem = requestStep({
step_id: "edit",
method: "PUT",
path: "/api/items/{saved:item}",
});
expect(issues([sendsItem])).toEqual([
"Step edit sends saved value item, which no earlier step saves",
]);
expect(issues([sendsItem, saveIdStep("create", "item")])).toContainEqual(
expect.stringContaining("which no earlier step saves"),
);
expect(issues([saveIdStep("create", "item"), sendsItem])).toEqual([]);
expect(issues([clickSavingItem("item"), sendsItem])).toEqual([]);
for (const step of [
gotoStep(`${ORIGIN}/items/{saved:item}`),
gotoStep(`${ORIGIN}/items/%7Bsaved%3Aitem%7D`),
requestStep({ path: "/api/items?parent={saved:item}" }),
requestStep({ json: { list: [{ parent: "{saved:item}" }] } }),
requestStep({ json: { note: "Copied from {saved:item}" } }),
requestStep({ headers: [{ name: "X-Item", value: savedValue("item") }] }),
requestStep({
headers: [
{
name: "Authorization",
value: {
source: "basic_credentials",
parts: [savedValue("item"), { source: "literal", value: "x" }],
},
},
],
}),
])
expect(issues([step]), JSON.stringify(step)).toContainEqual(
expect.stringContaining("sends saved value item"),
);
});
it("reads only `{saved:name}` as written in a JSON string, not its encoded spelling", () => {
expect(
issues([requestStep({ json: { note: "%7Bsaved:item%7D" } })]),
).toEqual([]);
});
it("sends other braces as written", () => {
expect(
issues([
gotoStep(`${ORIGIN}/search/{q}`),
requestStep({ path: "/api/items/{id}", json: { name: "{draft}" } }),
]),
).toEqual([]);
});
});
describe("saved value schema: credentials and response paths", () => {
it("refuses a literal Basic password, which Evidence would record as written", () => {
const basic = (password: unknown) =>
requestStep({
headers: [
{
name: "Authorization",
value: {
source: "basic_credentials",
parts: [{ source: "literal", value: "admin" }, password],
},
},
],
});
expect(
issues([basic({ source: "literal", value: "hunter2" })]),
).toContainEqual(expect.stringContaining("not a literal"));
expect(issues([basic({ source: "literal", value: "" })])).toEqual([]);
});
it("builds Basic credentials from page values and refuses undeclared secret parts", () => {
const basic = (username: unknown, password: unknown) =>
requestStep({
headers: [
{
name: "Authorization",
value: { source: "basic_credentials", parts: [username, password] },
prefix: "Basic ",
},
],
});
expect(
issues([
basic(
{ source: "local_storage", key: "user", json_pointer: "/name" },
{ source: "cookie", name: "pw" },
),
]),
).toEqual([]);
expect(
issues([
basic(
{ source: "literal", value: "admin" },
{ source: "secret", secret_id: "password" },
),
]),
).toContainEqual(expect.stringContaining("not declared"));
const literal = { source: "literal", value: "admin" };
expect(
issues([
requestStep({
headers: [
{
name: "Authorization",
value: { source: "basic_credentials", parts: [literal] },
},
],
}),
]).length,
).toBeGreaterThan(0);
});
it("requires a save_response to name a same-origin path and something to save", () => {
const click = (saveResponse: unknown) => ({
...clickSavingItem("item"),
save_response: saveResponse,
});
for (const path of ["//evil.test/api", "api/items", "https://evil.test/"])
expect(
issues([
click({
method: "POST",
path,
save: [{ name: "item", json_pointer: "/id" }],
}),
]).length,
path,
).toBeGreaterThan(0);
expect(
issues([click({ method: "POST", path: "/api/items", save: [] })]).length,
).toBeGreaterThan(0);
});
});
describe("saved value execution", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("sends saved ids in the path and body with their saved type, and a saved secret only redacted", async () => {
const calls = stubFetch(200, [
'{"id":42,"session":{"token":"session-token-9f3e"}}',
]);
const parsed = scenario([
requestStep({
json: { title: "Scratch folder 1" },
save: [
{ name: "folder", json_pointer: "/id" },
{ name: "token", json_pointer: "/session/token", secret: true },
],
}),
requestStep({
step_id: "move",
method: "PUT",
path: "/api/folders/{saved:folder}",
json: {
title: "Moved",
parent: "{saved:folder}",
note: "Moved into {saved:folder}",
},
headers: [
{
name: "Authorization",
value: { source: "saved", name: "token" },
prefix: "Bearer ",
},
],
}),
]);
const secrets = resolved(parsed);
const page = fakePage({});
for (const action of parsed.actions) await perform(page, action, secrets);
expect(calls[1]).toEqual({
url: `${ORIGIN}/api/folders/42`,
init: expect.objectContaining({
method: "PUT",
headers: [
["Authorization", "Bearer session-token-9f3e"],
["content-type", "application/json"],
],
body: '{"title":"Moved","parent":42,"note":"Moved into 42"}',
}),
});
expect(secrets.redact("folder 42 with session-token-9f3e")).toBe(
"folder 42 with [REDACTED:page:create.token]",
);
});
});
describe("saved value credentials and limits", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("redacts Basic credentials and secret saves however short", async () => {
stubFetch(200, ['{"pin":"x7"}']);
const parsed = scenario(
[
requestStep({
headers: [
{
name: "X-Auth",
value: {
source: "basic_credentials",
parts: [
{ source: "secret", secret_id: "user" },
{ source: "secret", secret_id: "password" },
],
},
},
],
save: [{ name: "pin", json_pointer: "/pin", secret: true }],
}),
],
[
{ secret_id: "user", environment_variable: "APP_USER" },
{ secret_id: "password", environment_variable: "APP_PASSWORD" },
],
);
const secrets = resolved(parsed, { APP_USER: "a", APP_PASSWORD: "b" });
await perform(fakePage({}), parsed.actions[0]!, secrets);
const encoded = Buffer.from("a:b").toString("base64");
const redacted = secrets.redact(`X-Auth: ${encoded} pin x7`);
expect(redacted).not.toContain(encoded);
expect(redacted).not.toContain("x7");
});
it("saves no integer JSON parsing would round, so no later step names another object", async () => {
stubFetch(201, ['{"id":9007199254740993}']);
const parsed = scenario([
requestStep({ save: [{ name: "folder", json_pointer: "/id" }] }),
]);
const secrets = resolved(parsed);
await expect(
perform(fakePage({}), parsed.actions[0]!, secrets),
).rejects.toThrow("cannot be saved exactly");
expect(() => secrets.savedValue("folder")).toThrow();
});
it("sends a JSON string that spells a placeholder percent-encoded as written", async () => {
const calls = stubFetch(200, ['{"id":7}']);
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
requestStep({
step_id: "note",
json: { note: "%7Bsaved:item%7D", id: "{saved:item}" },
}),
]);
const secrets = resolved(parsed);
for (const action of parsed.actions)
await perform(fakePage({}), action, secrets);
expect(calls[1]!.init.body).toBe('{"note":"%7Bsaved:item%7D","id":7}');
});
it("stops reading a response to save from once it passes the limit", async () => {
const chunk = new Uint8Array(3_000_000).fill(97);
const read = vi
.fn()
.mockResolvedValueOnce({ done: false, value: chunk })
.mockResolvedValueOnce({ done: false, value: chunk })
.mockResolvedValueOnce({ done: false, value: chunk })
.mockResolvedValueOnce({ done: true });
const cancel = vi.fn(async () => undefined);
vi.stubGlobal("fetch", async () => ({
status: 200,
type: "basic",
body: { getReader: () => ({ read, cancel, releaseLock: vi.fn() }) },
}));
const parsed = scenario([
requestStep({ save: [{ name: "folder", json_pointer: "/id" }] }),
]);
await expect(
perform(fakePage({}), parsed.actions[0]!, resolved(parsed)),
).rejects.toThrow("too large to save values from");
expect(read).toHaveBeenCalledTimes(2);
expect(cancel).toHaveBeenCalledOnce();
});
it("fails a step whose response holds no value to save", async () => {
stubFetch(200, ['{"items":[]}', "not json"]);
const parsed = scenario([
requestStep({ save: [{ name: "folder", json_pointer: "/id" }] }),
]);
const secrets = resolved(parsed);
await expect(
perform(fakePage({}), parsed.actions[0]!, secrets),
).rejects.toThrow("no string or number at /id to save as folder");
await expect(
perform(fakePage({}), parsed.actions[0]!, secrets),
).rejects.toThrow("is not JSON");
expect(() => secrets.savedValue("folder")).toThrow();
});
it("encodes Basic credentials read from local storage and a cookie, and redacts both parts", async () => {
const calls = stubFetch(200);
const parsed = scenario([
requestStep({
headers: [
{
name: "Authorization",
value: {
source: "basic_credentials",
parts: [
{
source: "local_storage",
key: "user",
json_pointer: "/name",
},
{ source: "cookie", name: "api_key" },
],
},
prefix: "Basic ",
},
],
}),
]);
const secrets = resolved(parsed);
await perform(
fakePage({
cookies: [{ name: "api_key", value: "cookie-key-5d1c" }],
storage: { user: JSON.stringify({ name: "operator-7" }) },
}),
parsed.actions[0]!,
secrets,
);
const encoded = Buffer.from("operator-7:cookie-key-5d1c").toString(
"base64",
);
expect(calls[0]!.init.headers).toEqual([
["Authorization", `Basic ${encoded}`],
]);
const redacted = secrets.redact(`${encoded} operator-7 cookie-key-5d1c`);
for (const value of [encoded, "operator-7", "cookie-key-5d1c"])
expect(redacted).not.toContain(value);
});
});
describe("saved response JSON pointers", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("does not coerce a terminal newline in an array index", () => {
expect(responsePointerValue({ items: ["record-7"] }, "/items/0\n")).toBe(
undefined,
);
});
it.each(["length", "01", "-"])(
"refuses the non-index array token %s",
async (token) => {
stubFetch(201, ['{"items":["record-7","record-8"]}']);
const parsed = scenario([
requestStep({
save: [{ name: "item", json_pointer: `/items/${token}` }],
}),
]);
const secrets = resolved(parsed);
await expect(
perform(fakePage({}), parsed.actions[0]!, secrets),
).rejects.toThrow("has no string or number");
expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
},
);
it.each([
["/items/0", "record-7"],
["/length", 12],
["/01", "object-key"],
["/a~1b/~0key", "escaped-key"],
])("preserves JSON member identity at %s", async (pointer, expected) => {
stubFetch(201, [
'{"items":["record-7"],"length":12,"01":"object-key","a/b":{"~key":"escaped-key"}}',
]);
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: pointer }] }),
]);
const secrets = resolved(parsed);
await perform(fakePage({}), parsed.actions[0]!, secrets);
expect(secrets.savedValue("item")).toBe(expected);
});
});
describe("saved numbers JSON cannot spell back", () => {
it.each([
["0", 0],
["0.0", 0],
["1.5", 1.5],
["12.50", 12.5],
["1E2", 100],
["0.1", 0.1],
])(
"preserves an equivalent JSON decimal spelling (%s)",
async (number, kept) => {
stubFetch(201, [`{"id":${number}}`]);
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
]);
const secrets = resolved(parsed);
await perform(fakePage({}), parsed.actions[0]!, secrets);
expect(secrets.savedValue("item")).toBe(kept);
},
);
it.each(["-0", "1e-400", "-1e-400", "1.0000000000000000001"])(
"refuses a saved numeric identity that loses precision during parsing: %s",
async (number) => {
stubFetch(201, [`{"id":${number}}`]);
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
]);
const secrets = resolved(parsed);
await expect(
perform(fakePage({}), parsed.actions[0]!, secrets),
).rejects.toThrow("cannot be saved exactly");
expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
},
);
it("keeps an exact saved field when an unrelated response number is inexact", async () => {
stubFetch(201, ['{"id":"record-7","unrelated":1e-400}']);
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
]);
const secrets = resolved(parsed);
await perform(fakePage({}), parsed.actions[0]!, secrets);
expect(secrets.savedValue("item")).toBe("record-7");
});
afterEach(() => {
vi.unstubAllGlobals();
});
it.each(["1e400", "-1e400"])(
"saves no number JSON cannot spell back (%s)",
async (number) => {
stubFetch(201, [`{"id":${number}}`]);
const parsed = scenario([
requestStep({ save: [{ name: "folder", json_pointer: "/id" }] }),
]);
const secrets = resolved(parsed);
await expect(
perform(fakePage({}), parsed.actions[0]!, secrets),
).rejects.toThrow("not finite");
expect(() => secrets.savedValue("folder")).toThrow();
},
);
});
describe("values a URL path or Basic header cannot carry", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("fails a Basic header whose username holds a colon, before sending it", async () => {
const calls = stubFetch(200, ["{}"]);
const parsed = scenario(
[
requestStep({
headers: [
{
name: "Authorization",
value: {
source: "basic_credentials",
parts: [
{ source: "secret", secret_id: "user" },
{ source: "secret", secret_id: "password" },
],
},
},
],
}),
],
[
{ secret_id: "user", environment_variable: "APP_USER" },
{ secret_id: "password", environment_variable: "APP_PASSWORD" },
],
);
const secrets = resolved(parsed, {
APP_USER: "ops:admin",
APP_PASSWORD: "b",
});
await expect(
perform(fakePage({}), parsed.actions[0]!, secrets),
).rejects.toThrow("Basic username with a colon");
expect(calls).toHaveLength(0);
});
it.each([".", ".."])(
"fails a step whose saved %s fills a whole path segment, which the URL would drop",
async (dots) => {
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
{
step_id: "open",
action: "goto",
destination: { url: `${ORIGIN}/items/{saved:item}/edit` },
wait_until: "load",
},
requestStep({ step_id: "edit", path: "/api/items/{saved:item}" }),
]);
const secrets = resolved(parsed);
secrets.save("create.item", "item", dots, false);
const goto = vi.fn(async () => null);
await expect(
perform({ goto } as unknown as Page, parsed.actions[1]!, secrets),
).rejects.toThrow("dot segment");
expect(goto).not.toHaveBeenCalled();
await expect(
perform(fakePage({}), parsed.actions[2]!, secrets),
).rejects.toThrow("dot segment");
},
);
it.each([
["/api/items/{saved:item}.", `${ORIGIN}/items/{saved:item}.`],
["/api/items/%2e{saved:item}", `${ORIGIN}/items/%2E{saved:item}`],
["/safe\\{saved:item}.", `${ORIGIN}/safe\\{saved:item}.`],
["/safe\\%2e{saved:item}", `${ORIGIN}/safe\\%2e{saved:item}`],
])(
"fails a step whose saved . and the text beside it make a dot segment (%s)",
async (path, url) => {
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
{
step_id: "open",
action: "goto",
destination: { url },
wait_until: "load",
},
requestStep({ step_id: "edit", path }),
]);
const secrets = resolved(parsed);
secrets.save("create.item", "item", ".", false);
const goto = vi.fn(async () => null);
await expect(
perform({ goto } as unknown as Page, parsed.actions[1]!, secrets),
).rejects.toThrow("dot segment");
expect(goto).not.toHaveBeenCalled();
await expect(
perform(fakePage({}), parsed.actions[2]!, secrets),
).rejects.toThrow("dot segment");
},
);
it("sends dots that are part of a segment or of the query", async () => {
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
{
step_id: "open",
action: "goto",
destination: { url: `${ORIGIN}/items/v{saved:item}?at={saved:item}` },
wait_until: "load",
},
]);
const secrets = resolved(parsed);
secrets.save("create.item", "item", "..", false);
const goto = vi.fn(async () => null);
await perform({ goto } as unknown as Page, parsed.actions[1]!, secrets);
expect(goto).toHaveBeenCalledWith(
`${ORIGIN}/items/v..?at=..`,
expect.anything(),
);
});
});
describe("bounded derived credentials", () => {
it("refuses over-budget Unicode spellings before building encoded copies", () => {
const secrets = resolved(scenario([requestStep({})]));
const encode = vi.spyOn(globalThis, "encodeURIComponent");
try {
expect(() =>
secrets.save("create.token", "token", "私".repeat(1_000_000), true),
).toThrow("more than 4000000 characters");
expect(encode).not.toHaveBeenCalled();
expect(() => secrets.savedValue("token")).toThrow(
"No earlier step saved",
);
} finally {
encode.mockRestore();
}
});
it("charges distinct Basic credentials while admitting repeated identical spellings once", () => {
const secrets = resolved(scenario([requestStep({})]));
const password = "x".repeat(300_000);
secrets.save("create.token", "token", password, false);
const first = secrets.basicCredential("login.auth", "operator-0", password);
for (let index = 0; index < 8; index++)
expect(
secrets.basicCredential("again.auth", "operator-0", password),
).toBe(first);
expect(() => {
for (let index = 1; index < 16; index++)
secrets.basicCredential(
`s${String(index)}.auth`,
`operator-${String(index)}`,
password,
);
}).toThrow("more than 4000000 characters");
expect(secrets.savedValue("token")).toBe(password);
expect(secrets.redact(first)).not.toContain(first);
});
it("refuses excessive base64 expansion before allocating the credential buffer", () => {
const secrets = resolved(scenario([requestStep({})]));
const allocate = vi.spyOn(Buffer, "allocUnsafe");
try {
expect(() =>
secrets.basicCredential("login.auth", "u", "x".repeat(3_000_001)),
).toThrow("encoded form exceeds");
expect(allocate).not.toHaveBeenCalled();
} finally {
allocate.mockRestore();
}
});
});
describe("saved value retention", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("charges repeated identical secret saves only for retained values", () => {
const secrets = resolved(scenario([requestStep({})]));
const token = "s".repeat(1_000_000);
for (let index = 0; index < 8; index++)
secrets.save(`step${String(index)}.token`, "token", token, true);
expect(secrets.savedValue("token")).toBe(token);
expect(secrets.redact(token)).not.toContain(token);
});
it("charges trimmed secret spellings before changing either saved or redaction state", () => {
const secrets = resolved(scenario([requestStep({})]));
secrets.save("seed.fill", "fill", "x".repeat(3_999_000), false);
const trimmed = "a".repeat(198);
expect(() =>
secrets.save("step.padded", "padded", ` ${trimmed} `, true),
).toThrow("more than 4000000 characters of saved values");
expect(() => secrets.savedValue("padded")).toThrow("No earlier step saved");
expect(secrets.redact(trimmed)).toBe(trimmed);
expect(String(secrets.savedValue("fill"))).toHaveLength(3_999_000);
});
it("refuses a new saved name that would keep more than the limit, before keeping it", () => {
const secrets = resolved(scenario([requestStep({})]));
secrets.save("a.first", "first", "x".repeat(3_000_000), false);
expect(() =>
secrets.save("b.second", "second", "y".repeat(1_500_000), false),
).toThrow("more than 4000000 characters of saved values");
expect(() => secrets.savedValue("second")).toThrow("No earlier step saved");
// A later save of the same name replaces its value rather than adding to it.
secrets.save("c.first", "first", "z".repeat(3_500_000), false);
expect(String(secrets.savedValue("first"))).toHaveLength(3_500_000);
});
it("counts the redaction spellings a secret save keeps", () => {
const plain = resolved(scenario([requestStep({})]));
plain.save("a.token", "token", "t".repeat(2_500_000), false);
const secret = resolved(scenario([requestStep({})]));
expect(() =>
secret.save("a.token", "token", "t".repeat(2_500_000), true),
).toThrow("more than 4000000 characters of saved values");
});
it("fails the step that would save past the limit", async () => {
stubFetch(201, [`{"id":"${"y".repeat(200_000)}"}`]);
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
]);
const secrets = resolved(parsed);
secrets.save("seed.big", "big", "x".repeat(3_900_000), false);
await expect(
perform(fakePage({}), parsed.actions[0]!, secrets),
).rejects.toThrow(
"Step create: item cannot be saved: the scenario would keep more than",
);
});
});
describe("goto placeholders", () => {
it("fills a goto destination's placeholders, percent-encoded", async () => {
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
{
step_id: "open",
action: "goto",
destination: { url: `${ORIGIN}/items/{saved:item}/edit` },
wait_until: "load",
},
]);
const secrets = resolved(parsed);
secrets.save("create.item", "item", "a b/c", false);
const goto = vi.fn(async () => null);
await perform({ goto } as unknown as Page, parsed.actions[1]!, secrets);
expect(goto).toHaveBeenCalledWith(
`${ORIGIN}/items/a%20b%2Fc/edit`,
expect.anything(),
);
});
it("keeps a structured query value that spells a placeholder as written", async () => {
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
{
step_id: "open",
action: "goto",
destination: {
url: `${ORIGIN}/items/{saved:item}`,
query: [
{
name: "note",
value: { source: "literal", value: "{saved:item}" },
},
],
},
wait_until: "load",
},
]);
const secrets = resolved(parsed);
secrets.save("create.item", "item", "7", false);
const goto = vi.fn(async () => null);
await perform({ goto } as unknown as Page, parsed.actions[1]!, secrets);
const [address] = goto.mock.calls[0] as unknown as [string];
const url = new URL(address);
expect(url.pathname).toBe("/items/7");
expect(url.searchParams.get("note")).toBe("{saved:item}");
});
});
interface FakeResponse {
readonly method: string;
readonly url: string;
readonly status: number;
readonly body: string;
/** A request already running when the step began, such as an earlier autosave. */
readonly earlier?: boolean;
/** The body size the browser received, when it differs from the body's. */
readonly size?: number;
readonly encoding?: string;
/** The Content-Length the response declares. */
readonly length?: string;
/** A body still arriving: the text resolves with it. */
readonly late?: Promise<string>;
}
/**
* A page whose click starts the given requests, emitting each that did not start earlier to request listeners, and
* answers them in order; waitForResponse resolves with the first answer it names once the click acted.
*/
const uiPage = (responses: readonly FakeResponse[], clickFails = false) => {
const order: string[] = [];
const reads = { count: 0 };
const listeners = new Set<(request: unknown) => void>();
let acted: () => void = () => undefined;
const clicked = new Promise<void>((resolve) => {
acted = resolve;
});
const answers = responses.map((response) => {
const request = {
method: () => response.method,
url: () => response.url,
sizes: async () => ({
responseBodySize: response.size ?? response.body.length,
}),
};
return {
response,
request,
answer: {
request: () => request,
url: () => response.url,
status: () => response.status,
headers: () => ({
...(response.encoding === undefined
? {}
: { "content-encoding": response.encoding }),
...(response.length === undefined
? {}
: { "content-length": response.length }),
}),
body: async () => {
reads.count += 1;
return Buffer.from(await (response.late ?? response.body));
},
},
};
});
const page = {
on: (_event: string, listener: (request: unknown) => void) =>
listeners.add(listener),
off: (_event: string, listener: (request: unknown) => void) =>
listeners.delete(listener),
getByRole: () => ({
click: async () => {
order.push("click");
if (clickFails) throw new Error("Element is not attached");
for (const { response, request } of answers)
if (response.earlier !== true)
for (const listener of listeners) listener(request);
acted();
},
}),
waitForResponse: async (
names: (response: unknown) => boolean,
options: { timeout?: number },
) => {
order.push(`wait ${String(options.timeout)}`);
await clicked;
const found = answers.map(({ answer }) => answer).find(names);
if (found === undefined) throw new Error("Timeout exceeded");
return found;
},
} as unknown as Page;
return { page, order, listeners, reads };
};
const clickSaving = (path: string) =>
scenario([
{
step_id: "save-item",
action: "click",
timeout_ms: 5000,
locator: { kind: "role", role: "button", name: "Save" },
save_response: {
method: "POST",
path,
save: [{ name: "item", json_pointer: "/id" }],
},
},
]);
describe("ambiguous saved response observations", () => {
it("refuses an ambiguous poll and click request without choosing either identity", async () => {
const parsed = clickSaving("/api/items");
const secrets = resolved(parsed);
const { page, listeners } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: '{"id":"poll"}',
},
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: '{"id":"created"}',
},
]);
await expect(perform(page, parsed.actions[0]!, secrets)).rejects.toThrow(
"ambiguous",
);
expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
expect(listeners.size).toBe(0);
});
it("refuses a second matching request observed while the selected body is pending", async () => {
const parsed = clickSaving("/api/items");
const secrets = resolved(parsed);
let release!: (body: string) => void;
const late = new Promise<string>((resolve) => {
release = resolve;
});
const { page, listeners, reads } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: "",
size: 30,
late,
},
]);
const running = perform(page, parsed.actions[0]!, secrets);
const rejected = expect(running).rejects.toThrow("ambiguous");
await vi.waitFor(() => expect(reads.count).toBe(1));
for (const listener of listeners)
listener({ method: () => "POST", url: () => `${ORIGIN}/api/items` });
release('{"id":"wrong-object"}');
await rejected;
expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
expect(listeners.size).toBe(0);
});
});
describe("saving from a UI step's response", () => {
it("waits before clicking, and saves from the response the step names", async () => {
const parsed = clickSaving("/api/lists/{*}/items");
const secrets = resolved(parsed);
const { page, order } = uiPage([
{
method: "GET",
url: `${ORIGIN}/api/lists/3/items`,
status: 200,
body: '{"id":"read"}',
},
{
method: "POST",
url: `https://other.test/api/lists/3/items`,
status: 201,
body: '{"id":"foreign"}',
},
{
method: "POST",
url: `${ORIGIN}/api/lists/3/4/items`,
status: 201,
body: '{"id":"deeper"}',
},
{
method: "POST",
url: `${ORIGIN}/api/lists/3/items?draft=1`,
status: 201,
body: '{"id":"item-7"}',
},
]);
await perform(page, parsed.actions[0]!, secrets);
expect(order).toEqual(["wait 5000", "click"]);
expect(secrets.savedValue("item")).toBe("item-7");
});
it("saves from a request the step started, not one already running, and matches an encoded path", async () => {
const parsed = clickSaving("/api/lists/My List/items");
const secrets = resolved(parsed);
const { page, listeners } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/lists/My%20List/items`,
status: 201,
body: '{"id":"autosaved"}',
earlier: true,
},
{
method: "POST",
url: `${ORIGIN}/api/lists/My%20List/items`,
status: 201,
body: '{"id":"item-8"}',
},
]);
await perform(page, parsed.actions[0]!, secrets);
expect(secrets.savedValue("item")).toBe("item-8");
expect(listeners.size).toBe(0);
});
it("refuses a response too large to save from without reading it", async () => {
const parsed = clickSaving("/api/items");
const { page, reads } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: '{"id":"x"}',
size: 12_000_001,
},
]);
await expect(
perform(page, parsed.actions[0]!, resolved(parsed)),
).rejects.toThrow("too large to save values from");
expect(reads.count).toBe(0);
});
it.each([
{ encoding: "br", size: 32 },
{ encoding: "gzip, zstd", size: 32 },
{ encoding: "identity", size: 0 },
{ encoding: "gzip", size: 0 },
{ size: 0, length: "0" },
{ size: -1 },
{ size: Number.NaN },
])(
"refuses unknown decoded UI response bounds before body allocation: %j",
async (bounds) => {
const parsed = clickSaving("/api/items");
const secrets = resolved(parsed);
const { page, reads, listeners } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: '{"id":"record-7"}',
...bounds,
},
]);
await expect(perform(page, parsed.actions[0]!, secrets)).rejects.toThrow(
"decoded response size",
);
expect(reads.count).toBe(0);
expect(listeners.size).toBe(0);
expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
},
);
it("stops watching requests when the step fails before its response", async () => {
const parsed = clickSaving("/api/items");
const { page, listeners } = uiPage([], true);
await expect(
perform(page, parsed.actions[0]!, resolved(parsed)),
).rejects.toThrow("Element is not attached");
expect(listeners.size).toBe(0);
});
it("fails the step when no named response arrives or it is not 2xx", async () => {
const parsed = clickSaving("/api/items");
await expect(
perform(uiPage([]).page, parsed.actions[0]!, resolved(parsed)),
).rejects.toThrow(
"no POST /api/items response could be read, so nothing was saved: Timeout exceeded",
);
await expect(
perform(
uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 422,
body: '{"id":"x"}',
},
]).page,
parsed.actions[0]!,
resolved(parsed),
),
).rejects.toThrow(
"answered 422; values are saved only from a 2xx response",
);
});
});
it.each([false, true])(
"retains the physical response-wait failure despite late cancellation %s and removes its request watcher",
async (cancelAtCompletion) => {
const parsed = clickSaving("/api/items");
const secrets = resolved(parsed);
const { page, listeners } = uiPage([]);
const primary = new Error(
"page.waitForResponse: Target page has been closed",
);
vi.spyOn(page, "waitForResponse").mockRejectedValue(primary);
const cancel = new AbortController();
if (cancelAtCompletion) {
const remove = cancel.signal.removeEventListener.bind(cancel.signal);
const removing = vi
.spyOn(cancel.signal, "removeEventListener")
.mockImplementation((type, listener, options) => {
remove(type, listener, options);
cancel.abort("cancelled after the physical wait failed");
});
onTestFinished(() => removing.mockRestore());
}
const failure = await perform(
page,
parsed.actions[0]!,
secrets,
cancel.signal,
).catch((cause: unknown) => cause);
expect(failure).toMatchObject({ cause: primary });
expect((failure as Error).message).toContain(primary.message);
expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
expect(listeners.size).toBe(0);
},
);
it.each(["request", "click"])(
"retains saved values when cancellation follows completed %s publication",
async (kind) => {
onTestFinished(() => {
vi.unstubAllGlobals();
});
const parsed =
kind === "request"
? scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
])
: clickSaving("/api/items");
stubFetch(200, ['{"id":"committed-object"}']);
const page =
kind === "request"
? fakePage({})
: uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: '{"id":"committed-object"}',
},
]).page;
const secrets = resolved(parsed);
const cancel = new AbortController();
await perform(page, parsed.actions[0]!, secrets, cancel.signal);
cancel.abort("cancelled after publication");
expect(secrets.savedValue("item")).toBe("committed-object");
},
);
describe("saving from a compressed or cached UI response", () => {
it.each([
{ encoding: "gzip", size: 32 },
{ encoding: "deflate", size: 32 },
{ encoding: "x-gzip", size: 32 },
{ size: 0, length: "17" },
{ encoding: "gzip", size: 0, length: "32" },
])(
"saves from a response whose received size or declared length bounds its decoded size: %j",
async (bounds) => {
const parsed = clickSaving("/api/items");
const secrets = resolved(parsed);
const { page, reads } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: '{"id":"record-7"}',
...bounds,
},
]);
await perform(page, parsed.actions[0]!, secrets);
expect(secrets.savedValue("item")).toBe("record-7");
expect(reads.count).toBe(1);
},
);
it("refuses a compressed response that could decode past the limit without reading it", async () => {
const parsed = clickSaving("/api/items");
const { page, reads } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: '{"id":"x"}',
encoding: "gzip",
size: 12_000,
},
]);
await expect(
perform(page, parsed.actions[0]!, resolved(parsed)),
).rejects.toThrow("could decode past the size limit");
expect(reads.count).toBe(0);
});
});
describe("saved response limits and deadlines", () => {
afterEach(() => {
vi.useRealTimers();
});
const lateBody = () => {
let arrive: (body: string) => void = () => undefined;
const late = new Promise<string>((resolve) => {
arrive = resolve;
});
return { late, arrive };
};
it("saves nothing from a body still arriving when the step's time runs out", async () => {
vi.useFakeTimers();
const parsed = clickSaving("/api/items");
const secrets = resolved(parsed);
const { late, arrive } = lateBody();
const { page, listeners } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: "",
size: Buffer.byteLength('{"id":"item-late"}'),
late,
},
]);
const outcome = perform(page, parsed.actions[0]!, secrets).then(
() => "saved",
(cause: Error) => cause.message,
);
await vi.advanceTimersByTimeAsync(5001);
arrive('{"id":"item-late"}');
await expect(outcome).resolves.toContain(
"ended before its saved response was read",
);
expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
expect(listeners.size).toBe(0);
});
it("saves nothing from a body still arriving when the step is cancelled", async () => {
const parsed = clickSaving("/api/items");
const secrets = resolved(parsed);
const { late, arrive } = lateBody();
const { page, listeners } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: "",
size: Buffer.byteLength('{"id":"item-late"}'),
late,
},
]);
const cancel = new AbortController();
const outcome = perform(
page,
parsed.actions[0]!,
secrets,
cancel.signal,
).then(
() => "saved",
(cause: Error) => cause.message,
);
await new Promise((resolve) => setTimeout(resolve, 0));
cancel.abort();
arrive('{"id":"item-late"}');
await expect(outcome).resolves.toContain(
"ended before its saved response was read",
);
expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
expect(listeners.size).toBe(0);
});
it("saves nothing when cancellation arrives as the body wait releases its listener", async () => {
const parsed = clickSaving("/api/items");
const secrets = resolved(parsed);
const { page, listeners } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: '{"id":"late-object"}',
},
]);
const cancel = new AbortController();
const remove = cancel.signal.removeEventListener.bind(cancel.signal);
let waitsReleased = 0;
const removeListener = vi
.spyOn(cancel.signal, "removeEventListener")
.mockImplementation((type, listener, options) => {
remove(type, listener, options);
if (++waitsReleased === 2)
cancel.abort("cancelled at body wait completion");
});
onTestFinished(() => removeListener.mockRestore());
await expect(
perform(page, parsed.actions[0]!, secrets, cancel.signal),
).rejects.toThrow("ended before");
expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
expect(listeners.size).toBe(0);
});
it("reads a response whose received bytes exceed the character limit but whose text is within it", async () => {
const parsed = clickSaving("/api/items");
const secrets = resolved(parsed);
const { page } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: '{"id":"item-9"}',
// Two million three-byte characters, as CJK text spells them in UTF-8.
size: 6_000_000,
},
]);
await perform(page, parsed.actions[0]!, secrets);
expect(secrets.savedValue("item")).toBe("item-9");
});
it("fails a response whose text is longer than the character limit", async () => {
const parsed = clickSaving("/api/items");
const { page, reads } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 201,
body: `{"id":"${"x".repeat(4_000_000)}"}`,
},
]);
await expect(
perform(page, parsed.actions[0]!, resolved(parsed)),
).rejects.toThrow("too large to save values from");
expect(reads.count).toBe(1);
});
});
it.each(["array", "embedded", "headers"])(
"refuses repeated large saved JSON values in %s before expansion or browser dispatch",
async (kind) => {
onTestFinished(() => {
vi.unstubAllGlobals();
});
const parsed = scenario([
requestStep({ save: [{ name: "large", json_pointer: "/value" }] }),
requestStep({
step_id: "expanded",
headers:
kind === "headers"
? Array.from({ length: 1000 }, (_, i) => ({
name: `X-Repeated-${i}`,
value: { source: "saved" as const, name: "large" },
}))
: [],
json:
kind === "headers"
? undefined
: kind === "array"
? Array.from({ length: 1000 }, () => "{saved:large}")
: "prefix" + "{saved:large}".repeat(1000),
}),
]);
const secrets = resolved(parsed);
secrets.save("source.large", "large", "x".repeat(3_000_000), false);
const page = { ...fakePage({}), evaluate: vi.fn() } as unknown as Page;
await expect(perform(page, parsed.actions[1]!, secrets)).rejects.toThrow(
"serialization allowance",
);
expect(page.evaluate).not.toHaveBeenCalled();
},
);
it.each(["x".repeat(3_000_000), "私".repeat(100_000)])(
"refuses repeated query expansion before encoding or browser dispatch (case %#)",
async (value) => {
onTestFinished(() => {
vi.unstubAllGlobals();
});
const parsed = scenario([
requestStep({
query: Array.from({ length: 1000 }, (_, index) => ({
name: `repeated-${index}`,
value: { source: "literal", value },
})),
}),
]);
const secrets = resolved(parsed);
const page = { ...fakePage({}), evaluate: vi.fn() } as unknown as Page;
const encode = vi
.spyOn(globalThis, "encodeURIComponent")
.mockImplementation(() => {
throw new Error("Query encoding ran before expansion admission");
});
onTestFinished(() => encode.mockRestore());
await expect(perform(page, parsed.actions[0]!, secrets)).rejects.toThrow(
"serialization allowance",
);
expect(encode).not.toHaveBeenCalled();
expect(page.evaluate).not.toHaveBeenCalled();
},
);
it("refuses repeated goto query expansion before form encoding or browser navigation", async () => {
const value = "x".repeat(3_000_000);
const parsed = scenario([
{
...gotoStep(`${ORIGIN}/items?literal=a%20b#kept`),
destination: {
url: `${ORIGIN}/items?literal=a%20b#kept`,
query: Array.from({ length: 1000 }, (_, index) => ({
name: `repeated-${index}`,
value: { source: "literal", value },
})),
},
},
]);
const secrets = resolved(parsed);
const goto = vi.fn(async () => null);
const append = vi
.spyOn(URLSearchParams.prototype, "append")
.mockImplementation(() => {
throw new Error("Form encoding ran before expansion admission");
});
onTestFinished(() => append.mockRestore());
await expect(
perform({ goto } as unknown as Page, parsed.actions[0]!, secrets),
).rejects.toThrow("serialization allowance");
expect(append).not.toHaveBeenCalled();
expect(goto).not.toHaveBeenCalled();
});
it("preserves goto form query normalization, Unicode, replacement characters and the fragment", async () => {
const parsed = scenario([
{
...gotoStep(`${ORIGIN}/items?literal=a%20b#kept`),
destination: {
url: `${ORIGIN}/items?literal=a%20b#kept`,
query: [
{
name: "q",
value: { source: "literal", value: "a b私😀!~'\ud800" },
},
],
},
},
]);
const goto = vi.fn(async () => null);
await perform(
{ goto } as unknown as Page,
parsed.actions[0]!,
resolved(parsed),
);
expect(goto).toHaveBeenCalledWith(
`${ORIGIN}/items?literal=a+b&q=a+b%E7%A7%81%F0%9F%98%80%21%7E%27%EF%BF%BD#kept`,
expect.anything(),
);
});
it("refuses repeated saved URL expansion before browser navigation", async () => {
const parsed = scenario([
requestStep({ save: [{ name: "large", json_pointer: "/value" }] }),
{
step_id: "open",
action: "goto",
destination: { url: `${ORIGIN}/` + "{saved:large}".repeat(1000) },
wait_until: "load",
},
]);
const secrets = resolved(parsed);
secrets.save("source.large", "large", "x".repeat(3_000_000), false);
const goto = vi.fn();
await expect(
perform({ goto } as unknown as Page, parsed.actions[1]!, secrets),
).rejects.toThrow("serialization allowance");
expect(goto).not.toHaveBeenCalled();
});
it.each([
"/api\\items/{*}",
"/api/unused/../items/{*}",
"/api/unused/%2e%2e/items/{*}",
])(
"normalizes the entire observed response path %s before matching",
async (path) => {
const parsed = clickSaving(path);
const { page } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items/42`,
status: 201,
body: '{"id":"42"}',
},
]);
const secrets = resolved(parsed);
await perform(page, parsed.actions[0]!, secrets);
expect(secrets.savedValue("item")).toBe("42");
},
);
it("keeps literal encoded wildcard text distinct from observed-response wildcard segments", async () => {
const parsed = clickSaving("/api/%7B*%7D");
const { page } = uiPage([
{
method: "POST",
url: `${ORIGIN}/api/other`,
status: 201,
body: '{"id":"42"}',
},
]);
await expect(
perform(page, parsed.actions[0]!, resolved(parsed)),
).rejects.toThrow("no POST");
});
it("normalizes wildcard paths without colliding with literal marker-like segments", async () => {
const parsed = clickSaving("/~rea-wildcard~/items/{*}");
const { page } = uiPage([
{
method: "POST",
url: `${ORIGIN}/~rea-wildcard~/items/42`,
status: 201,
body: '{"id":"42"}',
},
]);
const secrets = resolved(parsed);
await perform(page, parsed.actions[0]!, secrets);
expect(secrets.savedValue("item")).toBe("42");
});
it("redacts a saved secret as a parsed URL query spells it", () => {
const secrets = resolved(scenario([requestStep({})]));
secrets.save("create.token", "token", "ab'cd~ef", true);
const sent = new URL(
`${ORIGIN}/api/items?token=${encodeURIComponent("ab'cd~ef")}`,
).href;
expect(sent).toContain("ab%27cd~ef");
expect(secrets.redact(sent)).toBe(
`${ORIGIN}/api/items?token=[REDACTED:page:create.token]`,
);
});