fix: harden Web UX capture profiles
This commit is contained in:
+25
-12
@@ -57,7 +57,9 @@ export WORKSPACE_ID='<workspace-id>'
|
||||
|
||||
Authentication state is local sensitive material. `.web-ux/` is gitignored, files are written with
|
||||
mode `0600`, state contents are never copied into a review bundle, and the CLI never prints cookies
|
||||
or credentials.
|
||||
or credentials. Each profile has a sidecar binding it to the exact persona and base URL origin with
|
||||
a 12-hour default expiry. Capture fails explicitly when metadata is missing, the origin differs, or
|
||||
the profile has expired; it never silently reuses or refreshes that state.
|
||||
|
||||
For an interactive Passkey/browser login:
|
||||
|
||||
@@ -78,7 +80,17 @@ without putting its value on the command line:
|
||||
deno task web-ux auth \
|
||||
--scenario scenarios/workspace-control-plane.json \
|
||||
--persona owner \
|
||||
--import-state /private/path/owner-state.json
|
||||
--import-state /private/path/owner-state.json \
|
||||
--expires-in-hours 8
|
||||
```
|
||||
|
||||
Delete both the profile and its metadata when it is no longer needed:
|
||||
|
||||
```sh
|
||||
deno task web-ux auth \
|
||||
--scenario scenarios/workspace-control-plane.json \
|
||||
--persona owner \
|
||||
--delete
|
||||
```
|
||||
|
||||
Do not place passwords, bearer tokens, private keys, WebAuthn material, or inline cookies in a
|
||||
@@ -93,7 +105,7 @@ Capture a stable multi-persona bundle:
|
||||
```sh
|
||||
deno task web-ux capture \
|
||||
--scenario scenarios/workspace-control-plane.json \
|
||||
--output ../../artifacts/web-ux \
|
||||
--output ../../target/web-ux \
|
||||
--run-id before-change
|
||||
```
|
||||
|
||||
@@ -102,7 +114,7 @@ Use filters for a bounded feedback loop:
|
||||
```sh
|
||||
deno task web-ux capture \
|
||||
--scenario scenarios/workspace-control-plane.json \
|
||||
--output ../../artifacts/web-ux \
|
||||
--output ../../target/web-ux \
|
||||
--run-id ticket-list-after \
|
||||
--personas owner,non-owner \
|
||||
--routes tickets \
|
||||
@@ -127,9 +139,9 @@ the new evidence. Playwright success alone is not visual acceptance.
|
||||
|
||||
```sh
|
||||
deno task web-ux compare \
|
||||
--before ../../artifacts/web-ux/before-change/review-context.json \
|
||||
--after ../../artifacts/web-ux/after-change/review-context.json \
|
||||
--output ../../artifacts/web-ux/before-vs-after
|
||||
--before ../../target/web-ux/before-change/review-context.json \
|
||||
--after ../../target/web-ux/after-change/review-context.json \
|
||||
--output ../../target/web-ux/before-vs-after
|
||||
```
|
||||
|
||||
`comparison.html` and `comparison.png` show before, after, and pixel diff side by side.
|
||||
@@ -150,13 +162,14 @@ that it did not start.
|
||||
Old complete review bundles can be removed without touching auth state or arbitrary directories:
|
||||
|
||||
```sh
|
||||
deno task web-ux cleanup --output ../../artifacts/web-ux --keep 5 --older-than-days 14 --dry-run
|
||||
deno task web-ux cleanup --output ../../artifacts/web-ux --keep 5 --older-than-days 14
|
||||
deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14 --dry-run
|
||||
deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14
|
||||
```
|
||||
|
||||
Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and
|
||||
`artifacts/web-ux/` are ignored by Git. Keep a bundle outside Git or publish it through the approved
|
||||
immutable artifact channel when durable review evidence is required.
|
||||
Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and the repository
|
||||
`target/` tree are ignored by Git. `capture` defaults to `target/web-ux` when `--output` is omitted.
|
||||
Keep a bundle outside Git or publish it through the approved immutable artifact channel when durable
|
||||
review evidence is required.
|
||||
|
||||
## Adding a scenario
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { assertEquals, assertRejects } from "@std/assert";
|
||||
import { join } from "@std/path";
|
||||
import { writeAuthMetadata } from "../src/auth_state.ts";
|
||||
import { capture } from "../src/capture.ts";
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
@@ -17,8 +18,9 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea
|
||||
const authDirectory = join(directory, "auth");
|
||||
await Deno.mkdir(authDirectory);
|
||||
for (const persona of ["owner", "non-owner"]) {
|
||||
const storageState = join(authDirectory, `${persona}.json`);
|
||||
await Deno.writeTextFile(
|
||||
join(authDirectory, `${persona}.json`),
|
||||
storageState,
|
||||
JSON.stringify({
|
||||
cookies: [{
|
||||
name: "persona",
|
||||
@@ -33,6 +35,7 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea
|
||||
origins: [],
|
||||
}),
|
||||
);
|
||||
await writeAuthMetadata(storageState, persona, baseUrl, 1);
|
||||
}
|
||||
const scenarioPath = join(directory, "scenario.json");
|
||||
await Deno.writeTextFile(
|
||||
|
||||
+19
-7
@@ -1,13 +1,17 @@
|
||||
#!/usr/bin/env -S deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys
|
||||
import { dirname, fromFileUrl, resolve } from "@std/path";
|
||||
import { authenticate, cleanup } from "./src/lifecycle.ts";
|
||||
import { capture, describeCapture } from "./src/capture.ts";
|
||||
import { compare } from "./src/compare.ts";
|
||||
|
||||
const DEFAULT_OUTPUT = resolve(dirname(fromFileUrl(import.meta.url)), "../..", "target/web-ux");
|
||||
|
||||
const HELP = `Web UX inspection workbench
|
||||
|
||||
Usage:
|
||||
deno task web-ux auth --scenario <file> --persona <id> [--base-url <url>] [--import-state <file>] [--headless]
|
||||
deno task web-ux capture --scenario <file> --output <directory> [--base-url <url>] [--run-id <id>] [--personas <ids>] [--routes <ids>] [--viewports <ids>] [--headed]
|
||||
deno task web-ux auth --scenario <file> --persona <id> [--base-url <url>] [--import-state <file>] [--expires-in-hours <hours>] [--headless]
|
||||
deno task web-ux auth --scenario <file> --persona <id> --delete
|
||||
deno task web-ux capture --scenario <file> [--output <directory>] [--base-url <url>] [--run-id <id>] [--personas <ids>] [--routes <ids>] [--viewports <ids>] [--headed]
|
||||
deno task web-ux compare --before <review-context.json> --after <review-context.json> --output <directory> [--threshold <0..1>]
|
||||
deno task web-ux cleanup --output <directory> [--keep <count>] [--older-than-days <days>] [--dry-run]
|
||||
|
||||
@@ -81,18 +85,26 @@ export async function main(rawArgs: string[]): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
if (args.command === "auth") {
|
||||
rejectUnknown(args, ["scenario", "persona", "base-url", "import-state", "timeout-ms"], [
|
||||
"headless",
|
||||
]);
|
||||
rejectUnknown(
|
||||
args,
|
||||
["scenario", "persona", "base-url", "import-state", "timeout-ms", "expires-in-hours"],
|
||||
["headless", "delete"],
|
||||
);
|
||||
const deleting = args.flags.has("delete");
|
||||
if (deleting && optional(args, "import-state")) {
|
||||
throw new Error("--delete cannot be combined with --import-state");
|
||||
}
|
||||
const path = await authenticate({
|
||||
scenarioPath: required(args, "scenario"),
|
||||
personaId: required(args, "persona"),
|
||||
baseUrl: optional(args, "base-url"),
|
||||
importState: optional(args, "import-state"),
|
||||
timeoutMs: integer(args, "timeout-ms"),
|
||||
expiresInHours: integer(args, "expires-in-hours"),
|
||||
delete: deleting,
|
||||
headless: args.flags.has("headless"),
|
||||
});
|
||||
console.log(`auth state saved: ${path}`);
|
||||
console.log(`auth state ${deleting ? "deleted" : "saved"}: ${path}`);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === "capture") {
|
||||
@@ -105,7 +117,7 @@ export async function main(rawArgs: string[]): Promise<number> {
|
||||
"routes",
|
||||
"viewports",
|
||||
], ["headed"]);
|
||||
const outputDirectory = required(args, "output");
|
||||
const outputDirectory = optional(args, "output") ?? DEFAULT_OUTPUT;
|
||||
const manifest = await capture({
|
||||
scenarioPath: required(args, "scenario"),
|
||||
outputDirectory,
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
"dataState": "Fresh browser context with no cookies, local storage, or session state.",
|
||||
"ready": { "kind": "network-idle", "timeoutMs": 15000 },
|
||||
"capturePoints": [{ "id": "initial", "label": "Anonymous entry", "fullPage": true }]
|
||||
},
|
||||
{
|
||||
"id": "account",
|
||||
"label": "Account entry",
|
||||
"path": "/account",
|
||||
"goal": "Understand current authentication state and the available account action without Workspace-private content.",
|
||||
"dataState": "Fresh browser context with no cookies, local storage, or session state.",
|
||||
"ready": { "kind": "network-idle", "timeoutMs": 15000 },
|
||||
"capturePoints": [{ "id": "initial", "label": "Anonymous account screen", "fullPage": true }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -57,6 +57,15 @@
|
||||
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
|
||||
"capturePoints": [{ "id": "initial", "label": "Worker list", "fullPage": true }]
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"label": "Workspace settings",
|
||||
"path": "/w/${WORKSPACE_ID}/settings",
|
||||
"goal": "Reach the relevant Workspace settings area without presenting owner-only destinations as usable actions to a non-owner.",
|
||||
"dataState": "Current permission projection for the selected Workspace.",
|
||||
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
|
||||
"capturePoints": [{ "id": "initial", "label": "Workspace settings", "fullPage": true }]
|
||||
},
|
||||
{
|
||||
"id": "repositories",
|
||||
"label": "Repository settings",
|
||||
@@ -65,6 +74,17 @@
|
||||
"dataState": "Workspace repository catalog projected through current permissions.",
|
||||
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
|
||||
"capturePoints": [{ "id": "initial", "label": "Repository settings", "fullPage": true }]
|
||||
},
|
||||
{
|
||||
"id": "repository-access",
|
||||
"label": "Repository access",
|
||||
"path": "/w/${WORKSPACE_ID}/settings/repository-access",
|
||||
"goal": "Review credential and host-trust bindings as an owner and verify non-owner composition fails closed without secret material.",
|
||||
"dataState": "Configured repository access bindings projected without credential bytes.",
|
||||
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
|
||||
"capturePoints": [
|
||||
{ "id": "initial", "label": "Repository access settings", "fullPage": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { writePrivateJson } from "./artifacts.ts";
|
||||
|
||||
export type AuthStateMetadata = {
|
||||
schemaVersion: 1;
|
||||
personaId: string;
|
||||
baseOrigin: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export function authMetadataPath(storageStatePath: string): string {
|
||||
return `${storageStatePath}.meta.json`;
|
||||
}
|
||||
|
||||
function baseOrigin(baseUrl: string): string {
|
||||
return new URL(baseUrl).origin;
|
||||
}
|
||||
|
||||
export async function writeAuthMetadata(
|
||||
storageStatePath: string,
|
||||
personaId: string,
|
||||
baseUrl: string,
|
||||
expiresInHours: number,
|
||||
): Promise<void> {
|
||||
if (!Number.isFinite(expiresInHours) || expiresInHours <= 0) {
|
||||
throw new Error("auth state expiry must be a positive number of hours");
|
||||
}
|
||||
const createdAt = new Date();
|
||||
const metadata: AuthStateMetadata = {
|
||||
schemaVersion: 1,
|
||||
personaId,
|
||||
baseOrigin: baseOrigin(baseUrl),
|
||||
createdAt: createdAt.toISOString(),
|
||||
expiresAt: new Date(createdAt.getTime() + expiresInHours * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
await writePrivateJson(authMetadataPath(storageStatePath), metadata);
|
||||
}
|
||||
|
||||
export async function validateAuthState(
|
||||
storageStatePath: string,
|
||||
personaId: string,
|
||||
baseUrl: string,
|
||||
now = new Date(),
|
||||
): Promise<void> {
|
||||
await Deno.stat(storageStatePath);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await Deno.readTextFile(authMetadataPath(storageStatePath)));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`auth state metadata is missing or invalid for ${personaId}; run the auth command again: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`auth state metadata is invalid for ${personaId}`);
|
||||
}
|
||||
const metadata = parsed as Partial<AuthStateMetadata>;
|
||||
if (metadata.schemaVersion !== 1 || metadata.personaId !== personaId) {
|
||||
throw new Error(`auth state metadata does not match persona ${personaId}`);
|
||||
}
|
||||
if (metadata.baseOrigin !== baseOrigin(baseUrl)) {
|
||||
throw new Error(
|
||||
`auth state for ${personaId} belongs to ${metadata.baseOrigin ?? "an unknown origin"}, not ${
|
||||
baseOrigin(baseUrl)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const expiresAt = Date.parse(metadata.expiresAt ?? "");
|
||||
if (!Number.isFinite(expiresAt)) throw new Error(`auth state expiry is invalid for ${personaId}`);
|
||||
if (expiresAt <= now.getTime()) {
|
||||
throw new Error(
|
||||
`auth state expired for ${personaId} at ${metadata.expiresAt}; run the auth command again`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAuthState(storageStatePath: string): Promise<void> {
|
||||
for (const path of [storageStatePath, authMetadataPath(storageStatePath)]) {
|
||||
try {
|
||||
await Deno.remove(path);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { basename, dirname, join, relative, resolve } from "@std/path";
|
||||
import { type Browser, chromium, type Page, type Response } from "playwright";
|
||||
import { validateAuthState } from "./auth_state.ts";
|
||||
import {
|
||||
assertBundleIsSecretFree,
|
||||
bounded,
|
||||
@@ -348,7 +349,7 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
||||
const storageState = persona.auth.kind === "storage-state"
|
||||
? resolveScenarioPath(scenarioPath, persona.auth.path)
|
||||
: undefined;
|
||||
if (storageState) await Deno.stat(storageState);
|
||||
if (storageState) await validateAuthState(storageState, persona.id, baseUrl);
|
||||
for (const viewport of viewports) {
|
||||
const context = await browser.newContext({
|
||||
storageState,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { dirname, resolve } from "@std/path";
|
||||
import { chromium } from "playwright";
|
||||
import { ensurePrivateDirectory, writePrivateJson } from "./artifacts.ts";
|
||||
import { ensurePrivateDirectory, makePrivate, writePrivateJson } from "./artifacts.ts";
|
||||
import { deleteAuthState, writeAuthMetadata } from "./auth_state.ts";
|
||||
import {
|
||||
interpolateEnvironment,
|
||||
loadScenario,
|
||||
@@ -14,6 +15,8 @@ export type AuthOptions = {
|
||||
baseUrl?: string;
|
||||
importState?: string;
|
||||
timeoutMs?: number;
|
||||
expiresInHours?: number;
|
||||
delete?: boolean;
|
||||
headless?: boolean;
|
||||
};
|
||||
|
||||
@@ -37,22 +40,28 @@ export async function authenticate(options: AuthOptions): Promise<string> {
|
||||
throw new Error(`persona ${persona.id} is anonymous and has no auth state`);
|
||||
}
|
||||
const outputPath = resolveScenarioPath(scenarioPath, persona.auth.path);
|
||||
await ensurePrivateDirectory(dirname(outputPath));
|
||||
if (options.importState) {
|
||||
const imported = validateStorageState(
|
||||
JSON.parse(await Deno.readTextFile(resolve(options.importState))),
|
||||
);
|
||||
await writePrivateJson(outputPath, imported);
|
||||
if (options.delete) {
|
||||
await deleteAuthState(outputPath);
|
||||
return outputPath;
|
||||
}
|
||||
if (!persona.login) {
|
||||
throw new Error(`persona ${persona.id} needs login configuration or --import-state`);
|
||||
}
|
||||
await ensurePrivateDirectory(dirname(outputPath));
|
||||
const baseUrl = validateBaseUrl(
|
||||
interpolateEnvironment(
|
||||
options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "",
|
||||
),
|
||||
);
|
||||
const expiresInHours = options.expiresInHours ?? 12;
|
||||
if (options.importState) {
|
||||
const imported = validateStorageState(
|
||||
JSON.parse(await Deno.readTextFile(resolve(options.importState))),
|
||||
);
|
||||
await writePrivateJson(outputPath, imported);
|
||||
await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours);
|
||||
return outputPath;
|
||||
}
|
||||
if (!persona.login) {
|
||||
throw new Error(`persona ${persona.id} needs login configuration or --import-state`);
|
||||
}
|
||||
const browser = await chromium.launch({ headless: options.headless ?? false });
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
@@ -67,7 +76,8 @@ export async function authenticate(options: AuthOptions): Promise<string> {
|
||||
});
|
||||
}
|
||||
await context.storageState({ path: outputPath });
|
||||
if (Deno.build.os !== "windows") await Deno.chmod(outputPath, 0o600);
|
||||
await makePrivate(outputPath);
|
||||
await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours);
|
||||
return outputPath;
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -38,8 +38,10 @@ async function waitForReady(url: string, timeoutMs: number): Promise<void> {
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2_000) });
|
||||
if (response.status < 500) return;
|
||||
lastError = `HTTP ${response.status}`;
|
||||
const status = response.status;
|
||||
await response.body?.cancel();
|
||||
if (status < 500) return;
|
||||
lastError = `HTTP ${status}`;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { assertRejects } from "@std/assert";
|
||||
import { join } from "@std/path";
|
||||
import {
|
||||
authMetadataPath,
|
||||
deleteAuthState,
|
||||
validateAuthState,
|
||||
writeAuthMetadata,
|
||||
} from "../src/auth_state.ts";
|
||||
|
||||
Deno.test("auth state is bound to persona, base origin, and expiry", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
try {
|
||||
const state = join(directory, "owner.json");
|
||||
await Deno.writeTextFile(state, '{"cookies":[],"origins":[]}');
|
||||
await writeAuthMetadata(state, "owner", "https://example.test/path", 1);
|
||||
await validateAuthState(state, "owner", "https://example.test/other");
|
||||
await assertRejects(
|
||||
() => validateAuthState(state, "non-owner", "https://example.test"),
|
||||
Error,
|
||||
"does not match persona",
|
||||
);
|
||||
await assertRejects(
|
||||
() => validateAuthState(state, "owner", "https://other.test"),
|
||||
Error,
|
||||
"belongs to https://example.test",
|
||||
);
|
||||
await assertRejects(
|
||||
() =>
|
||||
validateAuthState(
|
||||
state,
|
||||
"owner",
|
||||
"https://example.test",
|
||||
new Date(Date.now() + 2 * 60 * 60 * 1000),
|
||||
),
|
||||
Error,
|
||||
"auth state expired",
|
||||
);
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("auth state deletion removes state and metadata idempotently", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
try {
|
||||
const state = join(directory, "owner.json");
|
||||
await Deno.writeTextFile(state, "{}");
|
||||
await writeAuthMetadata(state, "owner", "http://127.0.0.1:3000", 1);
|
||||
await deleteAuthState(state);
|
||||
await deleteAuthState(state);
|
||||
await assertRejects(() => Deno.stat(state), Deno.errors.NotFound);
|
||||
await assertRejects(() => Deno.stat(authMetadataPath(state)), Deno.errors.NotFound);
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user