Files
reverse-engineering-anything/scripts/lib/browser-request-e2e.mjs
T
Edgar ArakelyanandClaude Opus 5.5 74c2a12410 fix(browser): close request step edge cases from the second review
- Append query entries correctly after a path ending in "?", "&" or "#".
- Refuse 3xx expected statuses, which an unfollowed redirect can never
  report, and say a redirected write may already have been applied.
- Redact a page value also as fetch sends it, without surrounding spaces
  and tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:01:07 -07:00

292 lines
9.6 KiB
JavaScript

import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
import { browserScenarioCaptureSchema } from "../../dist/domain/browserScenarioCapture.js";
import { parseEvidence } from "../../dist/domain/evidence.js";
import {
requestFixtureCsrf,
requestFixtureToken,
startBrowserRequestSite,
} from "../fixtures/browser-request-site.mjs";
const run = promisify(execFile);
const authenticated = [
{ name: "X-CSRFToken", value: { source: "cookie", name: "csrftoken" } },
{
name: "Authorization",
value: { source: "local_storage", key: "auth", json_pointer: "/token" },
prefix: "Bearer ",
},
];
const read = (step_id, path) => ({
step_id,
action: "request",
method: "GET",
path,
expected_status: [200],
});
const write = (step_id, method, path, { json, status }) => ({
step_id,
action: "request",
method,
path,
headers: authenticated,
...(json === undefined ? {} : { json }),
expected_status: [status],
});
const pause = (step_id) => ({
step_id,
action: "wait_for_timeout",
duration_ms: 1100,
});
/** A recording that writes two scratch items and reads each write back at once, as an environment needs. */
const writeSession = (executable, origin) =>
browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: executable },
start_url: { url: `${origin}/` },
actions: [
{
step_id: "ready",
action: "wait_for",
locator: { kind: "css", selector: "#ready" },
state: "visible",
timeout_ms: 10_000,
},
read("list-1", "/api/items"),
read("list-2", "/api/items"),
read("item-1", "/api/items/1"),
write("create-1", "POST", "/api/items", {
json: { title: "Scratch item 1" },
status: 201,
}),
read("created-1", "/api/items/3"),
read("list-created-1", "/api/items"),
write("create-2", "POST", "/api/items", {
json: { title: "Scratch item 2" },
status: 201,
}),
read("created-2", "/api/items/4"),
read("list-created-2", "/api/items"),
pause("settle-creates"),
write("update-1", "PUT", "/api/items/3", {
json: { title: "Scratch item 1 renamed", done: true },
status: 200,
}),
read("updated-1", "/api/items/3"),
read("list-updated-1", "/api/items"),
write("update-2", "PUT", "/api/items/4", {
json: { title: "Scratch item 2 renamed", done: false },
status: 200,
}),
read("updated-2", "/api/items/4"),
read("list-updated-2", "/api/items"),
write("delete-1", "DELETE", "/api/items/3", { status: 204 }),
{ ...read("deleted-1", "/api/items/3"), expected_status: [404] },
read("list-deleted-1", "/api/items"),
write("delete-2", "DELETE", "/api/items/4", { status: 204 }),
{ ...read("deleted-2", "/api/items/4"), expected_status: [404] },
read("list-deleted-2", "/api/items"),
read("session", "/api/session"),
],
capture: {
at_end: ["url"],
network: { request_body: true, response_body: true, header_values: true },
},
});
const runCli = async (entrypoint, args, maxBuffer) =>
(
await run(process.execPath, [entrypoint, ...args, "--json"], {
env: {
...process.env,
REA_LOG_LEVEL: "silent",
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper",
},
timeout: 120_000,
maxBuffer,
})
).stdout;
const captureOf = async (entrypoint, scenario, maxBuffer) => {
const stdout = await runCli(
entrypoint,
["capture-browser-scenario", JSON.stringify(scenario)],
maxBuffer,
);
const evidence = parseEvidence(JSON.parse(stdout));
return {
stdout,
evidence,
capture: browserScenarioCaptureSchema.parse(evidence.normalized_result),
};
};
/** Every request step completed and sent exactly its own request. */
const assertRequestsAttributed = (scenario, capture) => {
const steps = capture.steps.filter(
(step) => step.step_id !== "scenario_start",
);
assert.equal(steps.length, scenario.actions.length);
for (const step of steps)
assert.equal(step.status, "completed", JSON.stringify(step));
const requests = capture.events.items.filter(
(event) => event.kind === "request",
);
scenario.actions.forEach((action, index) => {
if (action.action !== "request") return;
const stepIndex = capture.steps.findIndex(
(step) => step.step_id === action.step_id,
);
assert.equal(capture.steps[stepIndex].action, "request");
const sent = requests.filter(
(event) =>
event.step_index === stepIndex &&
event.method === action.method &&
new URL(event.url.url).pathname === action.path,
);
assert.equal(sent.length, 1, `step ${String(index)} ${action.step_id}`);
});
};
/** The CSRF cookie and stored token reached the server but no record keeps them. */
const assertPageValuesRedacted = (evidence, capture) => {
// Bodies are base64 in the Evidence, so decode them before looking for the values.
const contents = capture.events.items.filter(
(event) => event.kind === "network-content",
);
const bodies = contents
.filter((event) => event.body.state === "captured")
.map((event) => Buffer.from(event.body.content, "base64").toString());
const headerValues = contents.flatMap((event) =>
event.headers.state === "captured"
? event.headers.items.map((item) => item.value ?? "")
: [],
);
const retained = [JSON.stringify(evidence), ...bodies, ...headerValues];
for (const value of [requestFixtureCsrf, requestFixtureToken])
assert.ok(
retained.every((text) => !text.includes(value)),
`${value} was retained`,
);
assert.ok(
headerValues.some((value) =>
value.includes("[REDACTED:page:create-1.X-CSRFToken]"),
),
"the CSRF header was not recorded redacted",
);
assert.ok(
bodies.some(
(body) =>
body.includes('"token"') && body.includes("[REDACTED:page:create-1."),
),
"the session response was not recorded redacted",
);
assert.ok(
capture.limitations.some((line) => line.startsWith("Request steps sent")),
);
};
/** A status outside expected_status fails the step and cancels the rest; a redirect to another origin is not followed. */
const assertUnexpectedStatusFails = async (entrypoint, scenario, site) => {
const failing = browserScenarioSchema.parse({
...scenario,
actions: [
write("delete-missing", "DELETE", "/api/items/999", { status: 204 }),
read("after", "/api/items"),
],
});
const { capture } = await captureOf(entrypoint, failing, 16 * 1024 * 1024);
const missing = capture.steps.find(
({ step_id }) => step_id === "delete-missing",
);
assert.equal(missing.status, "failed");
assert.match(missing.error ?? "", /answered 404; expected 204/u);
assert.equal(
capture.steps.find(({ step_id }) => step_id === "after").status,
"cancelled",
);
const redirected = browserScenarioSchema.parse({
...scenario,
actions: [read("elsewhere", "/api/elsewhere")],
});
const followed = await captureOf(entrypoint, redirected, 16 * 1024 * 1024);
const step = followed.capture.steps.find(
({ step_id }) => step_id === "elsewhere",
);
assert.equal(step.status, "failed");
assert.match(step.error ?? "", /request steps do not follow/u);
// Chrome reports the redirected request and aborts it; the other origin must never receive it.
assert.ok(
site.hosts.every((line) => !line.startsWith("localhost")),
"a request step followed a redirect to another origin",
);
};
/**
* Record a write session through request steps in a real browser, then build
* an environment from it: every request lands in its own step, the page's
* CSRF cookie and stored token authenticate the writes and never reach the
* Evidence, and the build serves the recorded writes.
*/
export async function verifyBrowserRequestSteps(
executable,
entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)),
) {
const site = await startBrowserRequestSite();
const root = await mkdtemp(join(tmpdir(), "rea-request-e2e-"));
try {
const scenario = writeSession(executable, site.origin);
const { stdout, evidence, capture } = await captureOf(
entrypoint,
scenario,
64 * 1024 * 1024,
);
assertRequestsAttributed(scenario, capture);
assert.equal(site.writes.length, 6);
assert.ok(site.writes.every(({ csrf, bearer }) => csrf && bearer));
assertPageValuesRedacted(evidence, capture);
const capturePath = join(root, "items.capture.json");
await writeFile(capturePath, stdout);
const built = JSON.parse(
await runCli(
entrypoint,
["environment-build", capturePath, join(root, "env")],
16 * 1024 * 1024,
),
);
assert.equal(built.status, "built");
assert.equal(built.name, "items");
const served = built.coverage.writes.served.map(({ tool }) => tool).sort();
assert.deepEqual(
served,
["delete_items_by_id", "post_items", "put_items_by_id"],
JSON.stringify(built.coverage),
);
await assertUnexpectedStatusFails(entrypoint, scenario, site);
return {
request_steps: scenario.actions.filter(
({ action }) => action === "request",
).length,
authenticated_writes: site.writes.length,
served_writes: served,
tool_count: built.tool_count,
};
} finally {
await rm(root, { recursive: true, force: true });
await site.close();
}
}