diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index d173763599..53142c642a 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -2,7 +2,7 @@ name: reverse-engineer-anything description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA, and build agent environments (AgentEnv bundles) of web applications from a live instance or a capture. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. metadata: - version: "49" + version: "50" --- # REA diff --git a/.agents/skills/reverse-engineer-anything/references/environments.md b/.agents/skills/reverse-engineer-anything/references/environments.md index 08a9b68048..32d1cf2d74 100644 --- a/.agents/skills/reverse-engineer-anything/references/environments.md +++ b/.agents/skills/reverse-engineer-anything/references/environments.md @@ -101,10 +101,7 @@ shape, with the application's own locators, paths and headers: "action": "request", "timeout_ms": 15000, "method": "GET", - "path": "/api/folders/{id}", - "path_params": [ - { "name": "id", "value": { "source": "saved", "name": "folder_1" } } - ], + "path": "/api/folders/{saved:folder_1}", "expected_status": [200] } ] @@ -121,12 +118,17 @@ client with it. Match `wait_for` locators to exactly one element. 2. Read with `request` steps: each collection the environment needs, twice before any write, so fields that change on their own show up; one item of each. -3. Write with `request` steps too. When the application assigns an object's - ID, `save` it from the create's response and send it to later steps through - `path_params` (a `{name}` placeholder in `path`), `json_values` or a header: - never guess an ID, and never create a probe object to learn the next one. - Use the UI only for what the API cannot do; if a journey cannot be scripted, - leave the write unserved and report the limitation. +3. Write the way the application's UI does: fill its forms and click its + buttons, so each request carries the fields the UI sends, including ones + the server would otherwise derive. Give the click or key press that submits + a create `save_response` to keep the new object's ID, then `goto` its page + (`/items/{saved:item_1}`) or read it with a `request` step. Never guess an + ID, and never create a probe object to learn the next one. Write with + `request` steps instead only when the UI cannot reach the operation (an + API-only resource, or a UI that does not call the JSON API), or when coverage + withholds the UI-shaped write as `unexplained-change` or `contradicted`; + that counts as one of the two re-recordings. If a journey cannot be + scripted, leave the write unserved and report the limitation. Record two numbered scratch objects per write type, with varied free text. After each write, and before the next one, read the object back by id and then its list: create 1, read 1, list, create 2, @@ -143,20 +145,27 @@ About `request` steps: (`{source: "cookie", name}`), a local storage entry (`{source: "local_storage", key, json_pointer}`), a saved value (`{source: "saved", name}`), or Basic credentials - (`{source: "basic_credentials", username, password}`, sent as the base64 of - `username:password` and redacted), with an optional `prefix` such as - `"Bearer "` or `"Basic "`. An API that takes Basic auth, or a header of - encoded credentials with no prefix, needs no session from the UI. Copy the + (`{source: "basic_credentials", parts: [username, password]}`, sent as the + base64 of `username:password` and redacted), with an optional `prefix` such as + `"Bearer "` or `"Basic "`. Each credential part is a literal, secret, saved + value, cookie or local storage entry, so a header the frontend builds from a + login token (the username and the token) can be copied too. An API that + takes Basic auth needs no session from the UI. Copy the headers the application's own requests send (a CSRF header, a bearer token). Values read from the page are redacted from records made after they are read, not from the login response that returned them, and values shorter than 8 characters are not redacted at all. - `save: [{name, json_pointer, secret}]` keeps a string or number from the step's JSON response, such as the created object's `/id`, for later steps; - `secret: true` redacts it, as for a token the API returns. Later steps send - it as `{source: "saved", name}` in `path_params`, `query`, headers, or - `json_values: [{json_pointer, value}]`, which sets it in the body with the - JSON type it was saved with. + `secret: true` redacts it, as for a token the API returns, so mark every + saved token, key or session value secret. On a `click` or `press`, + `save_response: {method, path, save}` keeps values from the response to the + request the step triggers (`{*}` in `path` matches one segment); the step + fails unless a 2xx JSON response arrives before its `timeout_ms`. Later + steps send a saved value as `{saved:name}` in a request `path` (and its + query) or a `goto` URL; in the `json` body, where a string that is only + `{saved:name}` keeps the saved JSON type; or as `{source: "saved", name}` in + a header or credential part. - Reading a page value makes later bodies costlier to keep: after a CSRF token, bodies over about 47 KiB are kept as `unavailable`. When the API returns larger responses, raise `capture.limits` diff --git a/docs/agent-environments.md b/docs/agent-environments.md index a8fb4dfd3d..9ce5a93b90 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -203,7 +203,10 @@ attached and a transaction no record represents, and each transaction URL's query once. Bodies larger than 4 MiB of UTF-8 text are not scanned; `credential-scan-incomplete` counts them and lists up to 20 record or event pointers, including base64 bodies whose decoded scan was cut. An empty name -warning does not establish that these bodies were checked. Names do not establish sensitivity, +warning does not establish that these bodies were checked. A browser scenario's +saved values (`save` and `save_response`) named like credentials but not marked +`secret: true` are recorded unredacted; `credential-named-saved-values` lists up +to 20 of them by parameter pointer. Names do not establish sensitivity, so the build never refuses on them. Capture Evidence also records the capture file's absolute path (`subject.local_path`, diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md index 0be3b4b493..de4a9801c0 100644 --- a/docs/browser-scenario-contract.md +++ b/docs/browser-scenario-contract.md @@ -207,7 +207,8 @@ HTTP(S) URLs may include ordinary query values and fragments directly. Use ordered structured query entries when a value needs to reference a declared environment-backed secret. Form, storage, and cookie values are either literal strings or declared secret references; request step headers can also read a -cookie or local storage value from the page. Raw URL userinfo credentials are rejected. +cookie or local storage value from the page, and request steps and `goto` +destinations can send [saved values](#saved-values). Raw URL userinfo credentials are rejected. Captured credential-header values are not retained, and declared secret values are redacted automatically. A secret may be declared solely to @@ -400,6 +401,14 @@ data you may change. This scenario creates one item: by default) get smaller: after a 32-character `X-CSRFToken` value, a body over about 47 KiB is kept as `unavailable`. Raise `capture.limits` when the application's responses are larger. +- A header can send Basic credentials, `{"source": "basic_credentials", + "parts": [username, password]}`: the base64 of `username:password`, after a + `prefix` such as `"Basic "` or with none, as some APIs take encoded + credentials in their own header. Each part is a literal, a declared secret, a + saved value, a cookie or a local storage entry, so a header a frontend builds + from a login token can be sent too. The encoded value is redacted like a page value, as + `[REDACTED:page:.
]`, and parts read from the page are + redacted as page values are. - Validation refuses a header name given twice in a step, header names a page's `fetch` cannot set, such as `Cookie`, `Origin`, `Host` or `Sec-*`, and `User-Agent`, which Chrome drops; it refuses line breaks and other ASCII @@ -425,6 +434,77 @@ Captures with request steps state in `limitations` that the steps sent their declared requests through the page's fetch. A scenario without request steps is unchanged. +## Saved values + +A step can keep a string or number from a JSON response, by name, and a later +step can send it, so a scenario can read back, edit and delete an object whose +id the application assigns: + +```json +[ + { + "step_id": "save-item-1", + "action": "click", + "timeout_ms": 10000, + "locator": { "kind": "role", "role": "button", "name": "Save" }, + "save_response": { + "method": "POST", + "path": "/api/items", + "save": [{ "name": "item_1", "json_pointer": "/id" }] + } + }, + { + "step_id": "open-item-1", + "action": "goto", + "timeout_ms": 10000, + "destination": { "url": "http://127.0.0.1:3000/items/{saved:item_1}" }, + "wait_until": "load" + }, + { + "step_id": "copy-item-1", + "action": "request", + "timeout_ms": 10000, + "method": "POST", + "path": "/api/items/{saved:item_1}/copies", + "json": { + "source_id": "{saved:item_1}", + "title": "Copy of {saved:item_1}" + }, + "expected_status": [201] + } +] +``` + +- A `request` step saves with `save: [{name, json_pointer, secret}]`, from its + own response. A `click` or `press` step saves with `save_response: {method, + path, save}`, from the response to the request it triggers, such as the + create a form's Save button sends: the recorder starts waiting before the + step acts, matches the method and a path on the start origin (`{*}` matches + one segment, and the query is ignored), and fails the step unless a 2xx JSON + response arrives before its `timeout_ms`. A pointer that names no string or + number fails the step, so later steps never send a missing value. +- `{saved:name}` sends a saved value in a request step's `path`, including its + query, and in a `goto` destination's URL, percent-encoded. In a request + step's `json` body, a string that is only `{saved:name}` becomes the saved + value with the JSON type it was saved with, so a saved number id is sent as a + number, and `{saved:name}` inside a longer string is filled as text. A header + value or Basic credentials part sends one as `{"source": "saved", "name"}`. + Other braces, such as `{id}`, are sent as written. +- Validation refuses a saved value that no earlier step saves. A later save of + the same name replaces the value. +- A value saved with `secret: true` is redacted like a page value, as + `[REDACTED:page:.]`, from records made after it is saved. The + recorder reads response bodies on their own schedule, so the response it was + saved from may keep it, as a login response may keep a token a later step + reads from the page. Other saved values, such as ids, stay visible, since + environments model them. The environment build warns about saved values + named like credentials that are not marked secret + (`credential-named-saved-values`). +- Evidence records a request step's empty `save` list as absent, so a scenario + that saves nothing records the same parameters and `scenario_sha256` as + before saved values existed, and REA versions without saved values read its + captures. + ## Network evidence `capture.events: ["network"]` retains request, response, completion, failure, diff --git a/src/application/BrowserScenarioEvidence.test.ts b/src/application/BrowserScenarioEvidence.test.ts new file mode 100644 index 0000000000..ad564ad65e --- /dev/null +++ b/src/application/BrowserScenarioEvidence.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { scenarioCaptureFixture } from "../../tests/fixtures/browserScenarioCapture.js"; +import { browserScenarioSchema } from "../domain/browserScenario.js"; +import { digestCanonicalValue } from "../domain/canonicalDigest.js"; +import { createBrowserScenarioEvidence } from "./BrowserScenarioEvidence.js"; + +const ORIGIN = "http://127.0.0.1:3101"; + +const parameters = (actions: readonly unknown[]) => + createBrowserScenarioEvidence( + browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: "/opt/chromium/chrome" }, + start_url: { url: `${ORIGIN}/` }, + actions, + }), + scenarioCaptureFixture(), + { id: "fixture", name: "fixture", version: "1" }, + ).parameters; + +const open = (fields: Record = {}) => ({ + step_id: "open", + action: "goto", + destination: { url: `${ORIGIN}/items` }, + wait_until: "load", + ...fields, +}); + +const create = (fields: Record = {}) => ({ + step_id: "create", + action: "request", + method: "POST", + path: "/api/items", + json: { title: "Item 1" }, + ...fields, +}); + +describe("browser scenario evidence parameters", () => { + it("records a scenario that saves nothing as it was recorded before saved values existed", () => { + const recorded = parameters([open(), create()]); + const actions = recorded["actions"] as Record[]; + expect(Object.keys(actions[1]!)).not.toContain("save"); + expect(parameters([open(), create({ save: [] })])).toEqual(recorded); + const { scenario_sha256, ...scenario } = recorded; + expect(scenario_sha256).toBe( + digestCanonicalValue(scenario, "Browser scenario"), + ); + }); + + it("records the values a step saves and the placeholders that send them", () => { + const recorded = parameters([ + create({ save: [{ name: "item", json_pointer: "/id" }] }), + open({ destination: { url: `${ORIGIN}/items/{saved:item}` } }), + ]); + expect(recorded["actions"]).toMatchObject([ + { save: [{ name: "item", json_pointer: "/id", secret: false }] }, + { destination: { url: `${ORIGIN}/items/%7Bsaved:item%7D` } }, + ]); + expect(recorded["scenario_sha256"]).not.toBe( + parameters([create(), open()])["scenario_sha256"], + ); + }); +}); diff --git a/src/application/BrowserScenarioEvidence.ts b/src/application/BrowserScenarioEvidence.ts index 7fd9c2bd2e..1ef0b7a399 100644 --- a/src/application/BrowserScenarioEvidence.ts +++ b/src/application/BrowserScenarioEvidence.ts @@ -9,12 +9,27 @@ import { } from "../domain/evidence.js"; import { jsonObjectSchema } from "../domain/jsonValue.js"; +/** A step as Evidence records it: a request step leaves out an empty `save`, so a scenario that saves nothing records and hashes as it did before saved values existed. */ +const recordedAction = ( + action: BrowserScenario["actions"][number], +): Record => { + if (action.action !== "request" || action.save.length > 0) return action; + const { save, ...rest } = action; + return rest; +}; + const browserScenarioParameters = ( scenario: BrowserScenario, -): EvidenceObservation["parameters"] => ({ - ...jsonObjectSchema.parse(scenario), - scenario_sha256: digestCanonicalValue(scenario, "Browser scenario"), -}); +): EvidenceObservation["parameters"] => { + const recorded = { + ...scenario, + actions: scenario.actions.map(recordedAction), + }; + return { + ...jsonObjectSchema.parse(recorded), + scenario_sha256: digestCanonicalValue(recorded, "Browser scenario"), + }; +}; /** Create Evidence without retaining resolved scenario secret values. */ export const createBrowserScenarioEvidence = ( diff --git a/src/application/environment/environmentBuild.ts b/src/application/environment/environmentBuild.ts index f8325eb804..152337afd9 100644 --- a/src/application/environment/environmentBuild.ts +++ b/src/application/environment/environmentBuild.ts @@ -24,6 +24,7 @@ import { import { anonymousOrigin, credentialScanWarnings, + savedCredentialWarnings, nameFromCaptureFile, type EnvironmentBuildWarning, } from "./environmentBuildReport.js"; @@ -319,7 +320,10 @@ export const buildEnvironment = async ( name, operation: ENVIRONMENT_EXPORT_OPERATION, }); - warnings.push(...credentialScanWarnings(recorded.value)); + warnings.push( + ...credentialScanWarnings(recorded.value), + ...savedCredentialWarnings(capture.parameters), + ); const packageSource: EnvironmentPackageSource = input.packageTarball === undefined diff --git a/src/application/environment/environmentBuildReport.test.ts b/src/application/environment/environmentBuildReport.test.ts index c0153fed20..774fa36697 100644 --- a/src/application/environment/environmentBuildReport.test.ts +++ b/src/application/environment/environmentBuildReport.test.ts @@ -6,6 +6,7 @@ import { anonymousOrigin, credentialNamed, nameFromCaptureFile, + savedCredentialWarnings, scanCredentialNames, scanScenarioCredentialNames, } from "./environmentBuildReport.js"; @@ -455,3 +456,54 @@ it.each([ }); }, ); + +describe("savedCredentialWarnings", () => { + const entry = (name: string, secret?: boolean) => ({ + name, + json_pointer: "/x", + ...(secret === undefined ? {} : { secret }), + }); + + it("points at saved values named like credentials that are not marked secret", () => { + expect( + savedCredentialWarnings({ + actions: [ + { action: "goto" }, + { + action: "request", + save: [ + entry("item_id"), + entry("access_token"), + entry("api_key", true), + ], + }, + { + action: "click", + save_response: { save: [entry("password", false), entry("list")] }, + }, + ], + }), + ).toEqual([ + { + code: "credential-named-saved-values", + message: expect.stringContaining("2 saved values"), + pointers: [ + "/parameters/actions/1/save/1", + "/parameters/actions/2/save_response/save/0", + ], + }, + ]); + }); + + it("warns about nothing when every credential-named value is secret, or the parameters are not a scenario", () => { + expect( + savedCredentialWarnings({ + actions: [ + { action: "request", save: [entry("token", true), entry("id")] }, + ], + }), + ).toEqual([]); + expect(savedCredentialWarnings({ operation: "har" })).toEqual([]); + expect(savedCredentialWarnings(undefined)).toEqual([]); + }); +}); diff --git a/src/application/environment/environmentBuildReport.ts b/src/application/environment/environmentBuildReport.ts index 0ab133ce21..c4af005cc9 100644 --- a/src/application/environment/environmentBuildReport.ts +++ b/src/application/environment/environmentBuildReport.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { basename } from "node:path"; import { isIP } from "node:net"; @@ -330,6 +331,57 @@ export const credentialScanWarnings = (capture: { return warnings; }; +const savedEntries = z.array( + z.object({ name: z.string(), secret: z.boolean().optional() }), +); +const scenarioSaves = z.object({ + actions: z.array( + z + .object({ + save: savedEntries.optional(), + save_response: z.object({ save: savedEntries }).optional(), + }) + .loose(), + ), +}); + +/** A browser scenario's saved values named like credentials but not marked secret, which the capture records unredacted. */ +export const savedCredentialWarnings = ( + parameters: unknown, +): EnvironmentBuildWarning[] => { + const parsed = scenarioSaves.safeParse(parameters); + if (!parsed.success) return []; + const pointers: string[] = []; + parsed.data.actions.forEach((action, index) => { + const unmarked = ( + entries: + | readonly { + readonly name: string; + readonly secret?: boolean | undefined; + }[] + | undefined, + at: string, + ) => + entries?.forEach((entry, position) => { + if (entry.secret !== true && credentialNamed(entry.name)) + pointers.push( + `/parameters/actions/${String(index)}/${at}/${String(position)}`, + ); + }); + unmarked(action.save, "save"); + unmarked(action.save_response?.save, "save_response/save"); + }); + return pointers.length === 0 + ? [] + : [ + { + code: "credential-named-saved-values", + message: `${String(pointers.length)} saved values have credential-like names but are not marked secret, so the capture records them unredacted; mark them \`secret: true\` and record again before sharing.`, + pointers: pointers.slice(0, REPORTED_POINTERS), + }, + ]; +}; + /** Whether an origin's host is a loopback name or an IP literal, which says nothing about the application. */ export const anonymousOrigin = (origin: string): boolean => { let host: string; diff --git a/src/browser/BrowserScenarioSecrets.ts b/src/browser/BrowserScenarioSecrets.ts index a8de9b81c7..1700f481f4 100644 --- a/src/browser/BrowserScenarioSecrets.ts +++ b/src/browser/BrowserScenarioSecrets.ts @@ -319,10 +319,10 @@ export class BrowserScenarioSecrets { return ok(new BrowserScenarioSecrets(values)); } - /** Values request steps saved from their responses, by name, as JSON strings or numbers. */ + /** Values steps saved from JSON responses, by name, as strings or numbers. */ private readonly saved = new Map(); - /** Keep a value a request step saved from its response; a secret one is redacted from everything recorded after it. */ + /** Keep a value a step saved from a JSON response; a secret one is redacted from everything recorded after it. */ save( label: string, name: string, @@ -336,8 +336,7 @@ export class BrowserScenarioSecrets { /** A saved value with the JSON type it was saved with. */ savedValue(name: string): string | number { const value = this.saved.get(name); - if (value === undefined) - throw new Error(`No earlier request step saved ${name}`); + if (value === undefined) throw new Error(`No earlier step saved ${name}`); return value; } diff --git a/src/browser/PlaywrightScenarioActions.ts b/src/browser/PlaywrightScenarioActions.ts index ad6f3f2532..58d6e84c3e 100644 --- a/src/browser/PlaywrightScenarioActions.ts +++ b/src/browser/PlaywrightScenarioActions.ts @@ -5,7 +5,11 @@ import type { BrowserScenarioValue, } from "../domain/browserScenarioValues.js"; import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; -import { performScenarioRequest } from "./PlaywrightScenarioRequest.js"; +import { + awaitSavedResponse, + performScenarioRequest, + withSavedPlaceholders, +} from "./PlaywrightScenarioRequest.js"; type ScenarioLocator = Extract< BrowserScenarioAction, @@ -41,6 +45,22 @@ interface PerformActionInput { readonly signal?: AbortSignal | undefined; } +/** Start waiting for the response a click or key press names in `save_response`, before the step acts. */ +const savingResponse = ( + input: PerformActionInput, + action: Extract, + timeout: number | undefined, +): (() => Promise) | undefined => + action.save_response === undefined + ? undefined + : awaitSavedResponse(input.page, { + stepId: action.step_id, + spec: action.save_response, + startOrigin: input.startOrigin, + timeout, + secrets: input.secrets, + }); + /** Execute one admitted declarative action; a request step sends one declared request through the page's own fetch, and no action runs caller-supplied code. */ export const performPlaywrightScenarioAction = async ( input: PerformActionInput, @@ -50,27 +70,33 @@ export const performPlaywrightScenarioAction = async ( const options = timeout === undefined ? {} : { timeout }; switch (action.action) { case "goto": - await page.goto(secrets.url(action.destination), { - ...options, - waitUntil: action.wait_until, - }); + await page.goto( + withSavedPlaceholders(secrets.url(action.destination), secrets), + { ...options, waitUntil: action.wait_until }, + ); return; - case "click": + case "click": { + const saved = savingResponse(input, action, timeout); await resolveLocator(page, action.locator).click({ ...options, button: action.button, clickCount: action.click_count, }); + await saved?.(); return; + } case "fill": await resolveLocator(page, action.locator).fill( resolveValue(action.value, secrets), options, ); return; - case "press": + case "press": { + const saved = savingResponse(input, action, timeout); await resolveLocator(page, action.locator).press(action.key, options); + await saved?.(); return; + } case "select_option": await resolveLocator(page, action.locator).selectOption( resolveValue(action.value, secrets), diff --git a/src/browser/PlaywrightScenarioRequest.test.ts b/src/browser/PlaywrightScenarioRequest.test.ts index c31c7a2222..31c3ba753c 100644 --- a/src/browser/PlaywrightScenarioRequest.test.ts +++ b/src/browser/PlaywrightScenarioRequest.test.ts @@ -183,11 +183,11 @@ const fakePage = (options: { } as unknown as Page; }; -const stubFetch = (status: number) => { +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("{}", { status }); + return new Response(bodies[calls.length - 1] ?? "{}", { status }); }); return calls; }; @@ -589,3 +589,384 @@ describe("page value redaction", () => { expect(held.redact(text)).toBe(text); }); }); + +describe("saved value schema", () => { + const saved = (name: string) => ({ source: "saved", name }); + const saveId = (stepId: string, name: string) => + requestStep({ + step_id: stepId, + save: [{ name, json_pointer: "/id" }], + }); + const clickSaving = (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 gotoItem = (url: string) => ({ + step_id: "open", + action: "goto", + destination: { url }, + wait_until: "load", + }); + + 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, saveId("create", "item")])).toContainEqual( + expect.stringContaining("which no earlier step saves"), + ); + expect(issues([saveId("create", "item"), sendsItem])).toEqual([]); + expect(issues([clickSaving("item"), sendsItem])).toEqual([]); + for (const step of [ + gotoItem(`${ORIGIN}/items/{saved:item}`), + gotoItem(`${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: saved("item") }] }), + requestStep({ + headers: [ + { + name: "Authorization", + value: { + source: "basic_credentials", + parts: [saved("item"), { source: "literal", value: "x" }], + }, + }, + ], + }), + ]) + expect(issues([step]), JSON.stringify(step)).toContainEqual( + expect.stringContaining("sends saved value item"), + ); + }); + + it("sends other braces as written", () => { + expect( + issues([ + gotoItem(`${ORIGIN}/search/{q}`), + requestStep({ path: "/api/items/{id}", json: { name: "{draft}" } }), + ]), + ).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) => ({ + ...clickSaving("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]", + ); + }); + + 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); + }); + + 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(), + ); + }); +}); + +describe("saving from a UI step's response", () => { + interface FakeResponse { + readonly method: string; + readonly url: string; + readonly status: number; + readonly body: string; + } + + /** A page whose click triggers the given responses; waitForResponse resolves with the first one it names. */ + const uiPage = (responses: readonly FakeResponse[]) => { + const order: string[] = []; + const page = { + getByRole: () => ({ + click: async () => { + order.push("click"); + }, + }), + waitForResponse: async ( + names: (response: unknown) => boolean, + options: { timeout?: number }, + ) => { + order.push(`wait ${String(options.timeout)}`); + await Promise.resolve(); + const found = responses + .map((response) => ({ + request: () => ({ method: () => response.method }), + url: () => response.url, + status: () => response.status, + text: async () => response.body, + })) + .find(names); + if (found === undefined) throw new Error("Timeout exceeded"); + return found; + }, + } as unknown as Page; + return { page, order }; + }; + + 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" }], + }, + }, + ]); + + 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("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 arrived before the step's timeout", + ); + 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", + ); + }); +}); diff --git a/src/browser/PlaywrightScenarioRequest.ts b/src/browser/PlaywrightScenarioRequest.ts index a60b4c521f..fd1a08e7f3 100644 --- a/src/browser/PlaywrightScenarioRequest.ts +++ b/src/browser/PlaywrightScenarioRequest.ts @@ -2,8 +2,12 @@ import type { Page, Route } from "playwright-core"; import { HEADER_FIELD_VALUE, + SAVED_PLACEHOLDER, type BrowserScenarioAction, + type BrowserScenarioCredentialPart, type BrowserScenarioRequestHeader, + type BrowserScenarioSaveEntry, + type BrowserScenarioSaveResponse, } from "../domain/browserScenarioValues.js"; import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; @@ -59,31 +63,23 @@ const pointerValue = ( : undefined; }; -/** Resolve one header value; values read from the page are redacted from later records. */ -const headerValue = async ( - step: { - readonly page: Page; - readonly url: URL; - readonly stepId: string; - readonly secrets: BrowserScenarioSecrets; - }, - header: BrowserScenarioRequestHeader, +interface PageValueStep { + readonly page: Page; + readonly url: URL; + readonly stepId: string; + readonly secrets: BrowserScenarioSecrets; +} + +/** Read a value from the page, a cookie or a local storage entry, and redact it from later records under a label. */ +const readPageValue = async ( + step: PageValueStep, + value: Extract< + BrowserScenarioCredentialPart, + { source: "cookie" | "local_storage" } + >, + label: string, ): Promise => { const { page, url, stepId, secrets } = step; - const { value } = header; - if ( - value.source === "literal" || - value.source === "secret" || - value.source === "saved" - ) - return secrets.value(value); - if (value.source === "basic_credentials") { - const encoded = Buffer.from( - `${secrets.value(value.username)}:${secrets.value(value.password)}`, - ).toString("base64"); - secrets.admitPageValue(`${stepId}.${header.name}`, encoded); - return encoded; - } let read: string | undefined; if (value.source === "cookie") { const cookies = await page.context().cookies(url.href); @@ -133,10 +129,45 @@ const headerValue = async ( ); } } - secrets.admitPageValue(`${stepId}.${header.name}`, read); + secrets.admitPageValue(label, read); return read; }; +/** One part of Basic credentials: a literal, secret or saved value, or a value read from the page. */ +const credentialPart = ( + step: PageValueStep, + part: BrowserScenarioCredentialPart, + label: string, +): Promise | string => + part.source === "cookie" || part.source === "local_storage" + ? readPageValue(step, part, label) + : step.secrets.value(part); + +/** Resolve one header value; values read from the page, and credentials built from them, are redacted from later records. */ +const headerValue = async ( + step: PageValueStep, + header: BrowserScenarioRequestHeader, +): Promise => { + const { stepId, secrets } = step; + const { value } = header; + const label = `${stepId}.${header.name}`; + if ( + value.source === "literal" || + value.source === "secret" || + value.source === "saved" + ) + return secrets.value(value); + if (value.source === "basic_credentials") { + const [username, password] = value.parts; + const user = await credentialPart(step, username!, `${label}.username`); + const pass = await credentialPart(step, password!, `${label}.password`); + const encoded = Buffer.from(`${user}:${pass}`).toString("base64"); + secrets.admitPageValue(label, encoded); + return encoded; + } + return readPageValue(step, value, label); +}; + /** Append declared query entries after any literal query the path carries, leaving that query as written. */ const withQuery = ( url: URL, @@ -168,8 +199,8 @@ interface PageRequest { readonly headers: [string, string][]; readonly body: string | undefined; readonly deadline: number | undefined; - /** Return the response text, for a step that saves values from it. */ - readonly read: boolean; + /** Return the response text up to this many characters, for a step that saves values from it. */ + readonly readLimit: number | undefined; } /** The largest response text a request step saves values from. */ @@ -217,9 +248,11 @@ const sendFromPage = ( ...(request.body === undefined ? {} : { body: request.body }), }); if (answer.type === "opaqueredirect") return { redirected: true }; - if (request.read) { + if (request.readLimit !== undefined) { const text = await answer.text(); - return { status: answer.status, body: text }; + return text.length > request.readLimit + ? { status: answer.status } + : { status: answer.status, body: text }; } const reader = answer.body?.getReader(); if (reader !== undefined) @@ -262,16 +295,7 @@ export const performScenarioRequest = async (input: { throw new ScenarioRequestError( `Request step ${action.step_id} runs from a page on the start origin ${startOrigin}; navigate back to it with a goto step first.`, ); - const path = action.path.replace( - /\{([A-Za-z][A-Za-z0-9._-]*)\}/gu, - (placeholder, name: string) => { - const param = action.path_params.find((entry) => entry.name === name); - return param === undefined - ? placeholder - : encodeURIComponent(secrets.value(param.value)); - }, - ); - const url = new URL(path, startOrigin); + const url = new URL(withSavedPlaceholders(action.path, secrets), startOrigin); if (url.origin !== startOrigin) throw new ScenarioRequestError( `Request step ${action.step_id} must stay on the start origin ${startOrigin}.`, @@ -301,7 +325,8 @@ export const performScenarioRequest = async (input: { throw new ScenarioRequestError( `Request step ${action.step_id} ran out of time before its request was sent.`, ); - const json = withJsonValues(action, secrets); + const json = + action.json === undefined ? undefined : withSavedJson(action.json, secrets); const request = { url: target, origin: startOrigin, @@ -309,7 +334,8 @@ export const performScenarioRequest = async (input: { headers, body: json === undefined ? undefined : JSON.stringify(json), deadline, - read: action.save.length > 0, + readLimit: + action.save.length > 0 ? MAX_SAVED_RESPONSE_CHARACTERS : undefined, }; // The browser can queue evaluate behind a busy page. Keep a host-side gate // until that evaluation settles, including after the step boundary stops @@ -359,64 +385,135 @@ export const performScenarioRequest = async (input: { throw new ScenarioRequestError( `Request step ${action.step_id} answered ${String(response.status)}; expected ${action.expected_status.join(" or ")}.`, ); - if (action.save.length > 0) saveValues(action, response.body, secrets); + if (action.save.length > 0) + saveValues(action.step_id, response.body, action.save, secrets); }; -/** The step's JSON body with its `json_values` set at their pointers; a saved value keeps its JSON type. */ -const withJsonValues = ( - action: RequestAction, +/** A path or URL with its `{saved:name}` placeholders filled, each value percent-encoded. */ +export const withSavedPlaceholders = ( + text: string, + secrets: BrowserScenarioSecrets, +): string => + text.replace(SAVED_PLACEHOLDER, (_placeholder, name: string) => + encodeURIComponent(String(secrets.savedValue(name))), + ); + +/** Whether a request a UI step triggered is the one its `save_response` names: method, start origin, and path with `{*}` matching one segment. */ +const namesResponse = ( + spec: BrowserScenarioSaveResponse, + startOrigin: string, + method: string, + address: string, +): boolean => { + if (method !== spec.method) return false; + const url = URL.parse(address); + if (url === null || url.origin !== startOrigin) return false; + const pattern = spec.path.split("?")[0]!.split("/"); + const segments = url.pathname.split("/"); + return ( + segments.length === pattern.length && + pattern.every((part, index) => part === "{*}" || part === segments[index]) + ); +}; + +/** + * Start waiting, before a UI step acts, for the response its `save_response` names; the returned call waits for that + * response and saves its values. The step fails unless a 2xx JSON response arrives before its timeout, or the page's + * default timeout when it sets none. + */ +export const awaitSavedResponse = ( + page: Page, + step: { + readonly stepId: string; + readonly spec: BrowserScenarioSaveResponse; + readonly startOrigin: string; + readonly timeout: number | undefined; + readonly secrets: BrowserScenarioSecrets; + }, +): (() => Promise) => { + const { stepId, spec, startOrigin, timeout, secrets } = step; + const waiting = page.waitForResponse( + (response) => + namesResponse( + spec, + startOrigin, + response.request().method(), + response.url(), + ), + timeout === undefined ? {} : { timeout }, + ); + // The step may fail before it waits; the rejection is reported by the wait, or not at all. + waiting.catch(() => undefined); + return async () => { + let response: Awaited; + try { + response = await waiting; + } catch { + throw new ScenarioRequestError( + `Step ${stepId}: no ${spec.method} ${spec.path} response arrived before the step's timeout, so nothing was saved.`, + ); + } + if (response.status() < 200 || response.status() > 299) + throw new ScenarioRequestError( + `Step ${stepId}: the ${spec.method} ${spec.path} response answered ${String(response.status())}; values are saved only from a 2xx response.`, + ); + saveValues(stepId, await response.text(), spec.save, secrets); + }; +}; + +/** + * The step's JSON body with its `{saved:name}` placeholders filled: a string that is only a placeholder becomes the + * saved value, keeping its JSON type, and a placeholder inside a longer string is filled as text. + */ +const withSavedJson = ( + value: unknown, secrets: BrowserScenarioSecrets, ): unknown => { - if (action.json_values.length === 0) return action.json; - const body: unknown = structuredClone(action.json ?? {}); - for (const { json_pointer, value } of action.json_values) { - const tokens = json_pointer - .slice(1) - .split("/") - .map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~")); - const last = tokens.pop()!; - let parent: unknown = body; - for (const token of tokens) - parent = - parent !== null && typeof parent === "object" - ? (parent as Record)[token] - : undefined; - if (parent === null || typeof parent !== "object") - throw new ScenarioRequestError( - `Request step ${action.step_id}: the JSON body has no object or array at ${json_pointer}'s parent.`, - ); - (parent as Record)[last] = - value.source === "saved" - ? secrets.savedValue(value.name) - : secrets.value(value); + if (typeof value === "string") { + const [whole] = [...value.matchAll(SAVED_PLACEHOLDER)]; + if (whole !== undefined && whole[0] === value) + return secrets.savedValue(whole[1]!); + return value.replace(SAVED_PLACEHOLDER, (_placeholder, name: string) => + String(secrets.savedValue(name)), + ); } - return body; + if (Array.isArray(value)) + return value.map((item) => withSavedJson(item, secrets)); + if (value !== null && typeof value === "object") + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + withSavedJson(item, secrets), + ]), + ); + return value; }; -/** Save the values the step names from its JSON response, for later request steps. */ +/** Save the values a step names from a JSON response, for later steps. */ const saveValues = ( - action: RequestAction, + stepId: string, body: string | undefined, + entries: readonly BrowserScenarioSaveEntry[], secrets: BrowserScenarioSecrets, ): void => { if (body === undefined || body.length > MAX_SAVED_RESPONSE_CHARACTERS) throw new ScenarioRequestError( - `Request step ${action.step_id}: the response is too large to save values from.`, + `Step ${stepId}: the response is too large to save values from.`, ); let parsed: unknown; try { parsed = JSON.parse(body); } catch { throw new ScenarioRequestError( - `Request step ${action.step_id}: the response is not JSON, so no value can be saved from it.`, + `Step ${stepId}: the response is not JSON, so no value can be saved from it.`, ); } - for (const { name, json_pointer, secret } of action.save) { + for (const { name, json_pointer, secret } of entries) { const value = pointerValue(parsed, json_pointer); if (value === undefined) throw new ScenarioRequestError( - `Request step ${action.step_id}: the response has no string or number at ${json_pointer} to save as ${name}.`, + `Step ${stepId}: the response has no string or number at ${json_pointer} to save as ${name}.`, ); - secrets.save(`${action.step_id}.${name}`, name, value, secret); + secrets.save(`${stepId}.${name}`, name, value, secret); } }; diff --git a/src/domain/browserScenario.ts b/src/domain/browserScenario.ts index 5dc5159bb1..f4fd1867ef 100644 --- a/src/domain/browserScenario.ts +++ b/src/domain/browserScenario.ts @@ -13,9 +13,10 @@ import { HEADER_FIELD_VALUE, type BrowserScenarioAction, type BrowserScenarioUrl, - type BrowserScenarioRequestValue, type BrowserScenarioValue, + savedPlaceholderNames, } from "./browserScenarioValues.js"; +import type { JsonValue } from "./jsonValue.js"; export const browserScenarioInputSchema = z.strictObject({ browser: browserScenarioBrowserSchema.describe( @@ -59,26 +60,49 @@ const assertUnique = ( }; const secretReferencesInValue = ( - value: BrowserScenarioValue | BrowserScenarioRequestValue | undefined, + value: BrowserScenarioValue | undefined, ): readonly string[] => (value?.source === "secret" ? [value.secret_id] : []); -/** The values a request step sends: query, path and body values, headers, and both parts of Basic credentials. */ -const requestValues = ( +/** A request step's header values, with Basic credentials as their two parts. */ +const headerValues = ( action: Extract, -): readonly BrowserScenarioRequestValue[] => [ - ...action.query.map(({ value }) => value), - ...action.path_params.map(({ value }) => value), - ...action.json_values.map(({ value }) => value), - ...action.headers.flatMap(({ value }) => - value.source === "basic_credentials" - ? [value.username, value.password] - : value.source === "literal" || - value.source === "secret" || - value.source === "saved" - ? [value] - : [], - ), -]; +) => + action.headers.flatMap(({ value }) => + value.source === "basic_credentials" ? value.parts : [value], + ); + +/** Every string in a JSON value, at any depth. */ +const jsonStrings = (value: JsonValue | undefined): readonly string[] => { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap(jsonStrings); + if (value !== null && typeof value === "object") + return Object.values(value).flatMap(jsonStrings); + return []; +}; + +/** The saved values a step sends: `{saved:name}` in a request path, JSON string or goto URL, and saved header values and credential parts. */ +const sentSavedNames = (action: BrowserScenarioAction): readonly string[] => { + if (action.action === "goto") + return savedPlaceholderNames(action.destination.url); + if (action.action !== "request") return []; + return [ + ...[action.path, ...jsonStrings(action.json)].flatMap( + savedPlaceholderNames, + ), + ...headerValues(action).flatMap((value) => + value.source === "saved" ? [value.name] : [], + ), + ]; +}; + +/** The names a step saves: a request's `save`, or a UI step's `save_response`. */ +const savedNames = (action: BrowserScenarioAction): readonly string[] => + action.action === "request" + ? action.save.map(({ name }) => name) + : (action.action === "click" || action.action === "press") && + action.save_response !== undefined + ? action.save_response.save.map(({ name }) => name) + : []; const destinationSecretReferences = ( destination: BrowserScenarioUrl, @@ -93,36 +117,30 @@ const actionSecretReferences = ( if (action.action === "goto") return destinationSecretReferences(action.destination); if (action.action === "request") - return requestValues(action).flatMap(secretReferencesInValue); + return [ + ...action.query.flatMap(({ value }) => secretReferencesInValue(value)), + ...headerValues(action).flatMap((value) => + value.source === "secret" ? [value.secret_id] : [], + ), + ]; return []; }; -/** Each saved value a request step sends was saved by an earlier step, and each path placeholder has a value. */ +/** Each saved value a step sends was saved by an earlier step. */ const validateSavedValues = ( scenario: ScenarioShape, context: z.RefinementCtx, ): void => { const saved = new Set(); scenario.actions.forEach((action, index) => { - if (action.action !== "request") return; - for (const value of requestValues(action)) - if (value.source === "saved" && !saved.has(value.name)) + for (const name of new Set(sentSavedNames(action))) + if (!saved.has(name)) addIssue( context, ["actions", index], - `Request step ${action.step_id} sends saved value ${value.name}, which no earlier request step saves`, + `Step ${action.step_id} sends saved value ${name}, which no earlier step saves`, ); - const params = new Set(action.path_params.map(({ name }) => name)); - for (const [, name] of action.path.matchAll( - /\{([A-Za-z][A-Za-z0-9._-]*)\}/gu, - )) - if (!params.has(name!)) - addIssue( - context, - ["actions", index, "path"], - `The path placeholder {${name!}} has no path_params value`, - ); - for (const { name } of action.save) saved.add(name); + for (const name of savedNames(action)) saved.add(name); }); }; diff --git a/src/domain/browserScenarioValues.ts b/src/domain/browserScenarioValues.ts index 4b01ede4fc..85e2229a08 100644 --- a/src/domain/browserScenarioValues.ts +++ b/src/domain/browserScenarioValues.ts @@ -138,14 +138,21 @@ const browserScenarioPageValueSchemas = [ }), ] as const; -const jsonPointerSchema = z.string().regex(/^(?:\/.*)?$/u); +/** + * `{saved:name}`, which sends a saved value in a request path, a goto URL or a JSON body string. A parsed URL spells the + * braces, and may spell the colon, percent-encoded. + */ +export const SAVED_PLACEHOLDER = + /(?:\{|%7[Bb])saved(?::|%3[Aa])([A-Za-z][A-Za-z0-9._-]*)(?:\}|%7[Dd])/gu; -/** A value an earlier request step saved from its JSON response. */ +/** The saved value names a text's `{saved:name}` placeholders send. */ +export const savedPlaceholderNames = (text: string): readonly string[] => + [...text.matchAll(SAVED_PLACEHOLDER)].map(([, name]) => name!); + +/** A value an earlier step saved from a JSON response. */ const savedValueSchema = z.strictObject({ source: z.literal("saved"), - name: scenarioIdentifierSchema.describe( - "Name an earlier request step's `save` gave the value.", - ), + name: scenarioIdentifierSchema, }); /** A literal, declared secret or saved value a request step sends. */ @@ -157,13 +164,58 @@ export type BrowserScenarioRequestValue = z.infer< typeof browserScenarioRequestValueSchema >; +/** + * A part of Basic credentials: a literal, declared secret or saved value, or a value read from the page, as a frontend + * builds such a header from a login token. The page values carry no descriptions here, since each part repeats them. + */ +const basicCredentialPartSchema = z.discriminatedUnion("source", [ + ...browserScenarioRequestValueSchema.options, + z.strictObject({ source: z.literal("cookie"), name: z.string().min(1) }), + z.strictObject({ + source: z.literal("local_storage"), + key: z.string().min(1), + json_pointer: z + .string() + .regex(/^(?:\/.*)?$/u) + .optional(), + }), +]); +export type BrowserScenarioCredentialPart = z.infer< + typeof basicCredentialPartSchema +>; + /** A username and password sent as the base64 of `username:password`, as HTTP Basic auth spells them; redacted like a declared secret. */ const basicCredentialsSchema = z.strictObject({ source: z.literal("basic_credentials"), - username: browserScenarioRequestValueSchema, - password: browserScenarioRequestValueSchema, + parts: z + .array(basicCredentialPartSchema) + .length(2) + .describe("Username, then password."), }); +/** A string or number a step keeps from a JSON response, by name, for later steps. */ +const saveEntrySchema = z.strictObject({ + name: scenarioIdentifierSchema, + json_pointer: z.string().regex(/^(?:\/.*)?$/u), + secret: z.boolean().default(false), +}); +export type BrowserScenarioSaveEntry = z.infer; + +/** Values a UI step keeps from the JSON response to a request it triggers. */ +const saveResponseSchema = z + .strictObject({ + method: z.enum(BROWSER_SCENARIO_REQUEST_METHODS), + path: z + .string() + .regex(/^\/(?![/\\])/u) + .describe("`{*}` matches one segment; the query is ignored."), + save: z.array(saveEntrySchema).min(1), + }) + .describe( + "Save values from the 2xx JSON response to a request this step triggers.", + ); +export type BrowserScenarioSaveResponse = z.infer; + const requestHeaderSchema = z.strictObject({ name: z .string() @@ -311,6 +363,7 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [ locator: locatorSchema, button: z.enum(["left", "middle", "right"]).default("left"), click_count: z.int().positive().default(1), + save_response: saveResponseSchema.optional(), }), z.strictObject({ ...stepBase, @@ -323,6 +376,7 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [ action: z.literal("press"), locator: locatorSchema, key: z.string().min(1), + save_response: saveResponseSchema.optional(), }), z.strictObject({ ...stepBase, @@ -349,62 +403,20 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [ .string() .regex(/^\/(?![/\\])/u) .describe( - "Path, and optional literal query, on the start URL's origin, such as /api/folders; it must resolve to that origin. `{name}` placeholders take `path_params`.", + "Path, and optional literal query, on the start URL's origin, such as /api/folders; it must resolve to that origin.", ), - path_params: z - .array( - z.strictObject({ - name: scenarioIdentifierSchema, - value: browserScenarioRequestValueSchema, - }), - ) - .default([]) - .describe( - "Values for the path's `{name}` placeholders, each sent percent-encoded, such as an id an earlier step saved.", - ), - query: z - .array( - z.strictObject({ - name: z.string().min(1), - value: browserScenarioRequestValueSchema, - }), - ) - .default([]), + query: z.array(queryEntrySchema).default([]), headers: z.array(requestHeaderSchema).default([]), json: jsonValueSchema .optional() .describe( "JSON request body, sent with Content-Type application/json unless a header sets one; not allowed for GET or HEAD.", ), - json_values: z - .array( - z.strictObject({ - json_pointer: jsonPointerSchema.min(1), - value: browserScenarioRequestValueSchema, - }), - ) - .default([]) - .describe( - "Values set in the JSON body at these pointers before it is sent; a saved value keeps the JSON type it was saved with.", - ), save: z - .array( - z.strictObject({ - name: scenarioIdentifierSchema, - json_pointer: jsonPointerSchema.describe( - "Pointer to a string or number in the JSON response, such as /id.", - ), - secret: z - .boolean() - .default(false) - .describe( - "Redact the value from everything recorded after it, as a declared secret is.", - ), - }), - ) + .array(saveEntrySchema) .default([]) .describe( - "Values this step saves from its JSON response, by name, for later request steps to send.", + "Values to save from the JSON response. Later steps send one as `{saved:name}` in a request path, goto URL or JSON string, or as a header value.", ), expected_status: z .array( diff --git a/src/domain/environmentBundleExport.ts b/src/domain/environmentBundleExport.ts index d90ba29818..92119dccf1 100644 --- a/src/domain/environmentBundleExport.ts +++ b/src/domain/environmentBundleExport.ts @@ -105,6 +105,7 @@ export const environmentBuildReportSchema = z.strictObject({ "name-from-file", "credential-named-members", "credential-scan-incomplete", + "credential-named-saved-values", ]), message: z.string(), pointers: z diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index a683adc640..37b42b1bbe 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -6,5 +6,5 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.3.1", clientSdkVersion: "2.3.1", coreSdkVersion: "2.3.1", - skillVersion: "49", + skillVersion: "50", } as const;