fix: harden Web UX capture profiles
This commit is contained in:
@@ -6,4 +6,3 @@
|
|||||||
*.local*
|
*.local*
|
||||||
.env
|
.env
|
||||||
.web-ux/
|
.web-ux/
|
||||||
artifacts/web-ux/
|
|
||||||
|
|||||||
+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
|
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
|
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:
|
For an interactive Passkey/browser login:
|
||||||
|
|
||||||
@@ -78,7 +80,17 @@ without putting its value on the command line:
|
|||||||
deno task web-ux auth \
|
deno task web-ux auth \
|
||||||
--scenario scenarios/workspace-control-plane.json \
|
--scenario scenarios/workspace-control-plane.json \
|
||||||
--persona owner \
|
--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
|
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
|
```sh
|
||||||
deno task web-ux capture \
|
deno task web-ux capture \
|
||||||
--scenario scenarios/workspace-control-plane.json \
|
--scenario scenarios/workspace-control-plane.json \
|
||||||
--output ../../artifacts/web-ux \
|
--output ../../target/web-ux \
|
||||||
--run-id before-change
|
--run-id before-change
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -102,7 +114,7 @@ Use filters for a bounded feedback loop:
|
|||||||
```sh
|
```sh
|
||||||
deno task web-ux capture \
|
deno task web-ux capture \
|
||||||
--scenario scenarios/workspace-control-plane.json \
|
--scenario scenarios/workspace-control-plane.json \
|
||||||
--output ../../artifacts/web-ux \
|
--output ../../target/web-ux \
|
||||||
--run-id ticket-list-after \
|
--run-id ticket-list-after \
|
||||||
--personas owner,non-owner \
|
--personas owner,non-owner \
|
||||||
--routes tickets \
|
--routes tickets \
|
||||||
@@ -127,9 +139,9 @@ the new evidence. Playwright success alone is not visual acceptance.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
deno task web-ux compare \
|
deno task web-ux compare \
|
||||||
--before ../../artifacts/web-ux/before-change/review-context.json \
|
--before ../../target/web-ux/before-change/review-context.json \
|
||||||
--after ../../artifacts/web-ux/after-change/review-context.json \
|
--after ../../target/web-ux/after-change/review-context.json \
|
||||||
--output ../../artifacts/web-ux/before-vs-after
|
--output ../../target/web-ux/before-vs-after
|
||||||
```
|
```
|
||||||
|
|
||||||
`comparison.html` and `comparison.png` show before, after, and pixel diff side by side.
|
`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:
|
Old complete review bundles can be removed without touching auth state or arbitrary directories:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
deno task web-ux cleanup --output ../../artifacts/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 --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
|
||||||
```
|
```
|
||||||
|
|
||||||
Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and
|
Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and the repository
|
||||||
`artifacts/web-ux/` are ignored by Git. Keep a bundle outside Git or publish it through the approved
|
`target/` tree are ignored by Git. `capture` defaults to `target/web-ux` when `--output` is omitted.
|
||||||
immutable artifact channel when durable review evidence is required.
|
Keep a bundle outside Git or publish it through the approved immutable artifact channel when durable
|
||||||
|
review evidence is required.
|
||||||
|
|
||||||
## Adding a scenario
|
## Adding a scenario
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { assertEquals, assertRejects } from "@std/assert";
|
import { assertEquals, assertRejects } from "@std/assert";
|
||||||
import { join } from "@std/path";
|
import { join } from "@std/path";
|
||||||
|
import { writeAuthMetadata } from "../src/auth_state.ts";
|
||||||
import { capture } from "../src/capture.ts";
|
import { capture } from "../src/capture.ts";
|
||||||
|
|
||||||
async function freePort(): Promise<number> {
|
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");
|
const authDirectory = join(directory, "auth");
|
||||||
await Deno.mkdir(authDirectory);
|
await Deno.mkdir(authDirectory);
|
||||||
for (const persona of ["owner", "non-owner"]) {
|
for (const persona of ["owner", "non-owner"]) {
|
||||||
|
const storageState = join(authDirectory, `${persona}.json`);
|
||||||
await Deno.writeTextFile(
|
await Deno.writeTextFile(
|
||||||
join(authDirectory, `${persona}.json`),
|
storageState,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
cookies: [{
|
cookies: [{
|
||||||
name: "persona",
|
name: "persona",
|
||||||
@@ -33,6 +35,7 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea
|
|||||||
origins: [],
|
origins: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
await writeAuthMetadata(storageState, persona, baseUrl, 1);
|
||||||
}
|
}
|
||||||
const scenarioPath = join(directory, "scenario.json");
|
const scenarioPath = join(directory, "scenario.json");
|
||||||
await Deno.writeTextFile(
|
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
|
#!/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 { authenticate, cleanup } from "./src/lifecycle.ts";
|
||||||
import { capture, describeCapture } from "./src/capture.ts";
|
import { capture, describeCapture } from "./src/capture.ts";
|
||||||
import { compare } from "./src/compare.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
|
const HELP = `Web UX inspection workbench
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
deno task web-ux auth --scenario <file> --persona <id> [--base-url <url>] [--import-state <file>] [--headless]
|
deno task web-ux auth --scenario <file> --persona <id> [--base-url <url>] [--import-state <file>] [--expires-in-hours <hours>] [--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> --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 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]
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
if (args.command === "auth") {
|
if (args.command === "auth") {
|
||||||
rejectUnknown(args, ["scenario", "persona", "base-url", "import-state", "timeout-ms"], [
|
rejectUnknown(
|
||||||
"headless",
|
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({
|
const path = await authenticate({
|
||||||
scenarioPath: required(args, "scenario"),
|
scenarioPath: required(args, "scenario"),
|
||||||
personaId: required(args, "persona"),
|
personaId: required(args, "persona"),
|
||||||
baseUrl: optional(args, "base-url"),
|
baseUrl: optional(args, "base-url"),
|
||||||
importState: optional(args, "import-state"),
|
importState: optional(args, "import-state"),
|
||||||
timeoutMs: integer(args, "timeout-ms"),
|
timeoutMs: integer(args, "timeout-ms"),
|
||||||
|
expiresInHours: integer(args, "expires-in-hours"),
|
||||||
|
delete: deleting,
|
||||||
headless: args.flags.has("headless"),
|
headless: args.flags.has("headless"),
|
||||||
});
|
});
|
||||||
console.log(`auth state saved: ${path}`);
|
console.log(`auth state ${deleting ? "deleted" : "saved"}: ${path}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (args.command === "capture") {
|
if (args.command === "capture") {
|
||||||
@@ -105,7 +117,7 @@ export async function main(rawArgs: string[]): Promise<number> {
|
|||||||
"routes",
|
"routes",
|
||||||
"viewports",
|
"viewports",
|
||||||
], ["headed"]);
|
], ["headed"]);
|
||||||
const outputDirectory = required(args, "output");
|
const outputDirectory = optional(args, "output") ?? DEFAULT_OUTPUT;
|
||||||
const manifest = await capture({
|
const manifest = await capture({
|
||||||
scenarioPath: required(args, "scenario"),
|
scenarioPath: required(args, "scenario"),
|
||||||
outputDirectory,
|
outputDirectory,
|
||||||
|
|||||||
@@ -23,6 +23,15 @@
|
|||||||
"dataState": "Fresh browser context with no cookies, local storage, or session state.",
|
"dataState": "Fresh browser context with no cookies, local storage, or session state.",
|
||||||
"ready": { "kind": "network-idle", "timeoutMs": 15000 },
|
"ready": { "kind": "network-idle", "timeoutMs": 15000 },
|
||||||
"capturePoints": [{ "id": "initial", "label": "Anonymous entry", "fullPage": true }]
|
"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 },
|
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
|
||||||
"capturePoints": [{ "id": "initial", "label": "Worker list", "fullPage": true }]
|
"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",
|
"id": "repositories",
|
||||||
"label": "Repository settings",
|
"label": "Repository settings",
|
||||||
@@ -65,6 +74,17 @@
|
|||||||
"dataState": "Workspace repository catalog projected through current permissions.",
|
"dataState": "Workspace repository catalog projected through current permissions.",
|
||||||
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
|
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
|
||||||
"capturePoints": [{ "id": "initial", "label": "Repository settings", "fullPage": true }]
|
"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 { basename, dirname, join, relative, resolve } from "@std/path";
|
||||||
import { type Browser, chromium, type Page, type Response } from "playwright";
|
import { type Browser, chromium, type Page, type Response } from "playwright";
|
||||||
|
import { validateAuthState } from "./auth_state.ts";
|
||||||
import {
|
import {
|
||||||
assertBundleIsSecretFree,
|
assertBundleIsSecretFree,
|
||||||
bounded,
|
bounded,
|
||||||
@@ -348,7 +349,7 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
|||||||
const storageState = persona.auth.kind === "storage-state"
|
const storageState = persona.auth.kind === "storage-state"
|
||||||
? resolveScenarioPath(scenarioPath, persona.auth.path)
|
? resolveScenarioPath(scenarioPath, persona.auth.path)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (storageState) await Deno.stat(storageState);
|
if (storageState) await validateAuthState(storageState, persona.id, baseUrl);
|
||||||
for (const viewport of viewports) {
|
for (const viewport of viewports) {
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
storageState,
|
storageState,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { dirname, resolve } from "@std/path";
|
import { dirname, resolve } from "@std/path";
|
||||||
import { chromium } from "playwright";
|
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 {
|
import {
|
||||||
interpolateEnvironment,
|
interpolateEnvironment,
|
||||||
loadScenario,
|
loadScenario,
|
||||||
@@ -14,6 +15,8 @@ export type AuthOptions = {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
importState?: string;
|
importState?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
expiresInHours?: number;
|
||||||
|
delete?: boolean;
|
||||||
headless?: 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`);
|
throw new Error(`persona ${persona.id} is anonymous and has no auth state`);
|
||||||
}
|
}
|
||||||
const outputPath = resolveScenarioPath(scenarioPath, persona.auth.path);
|
const outputPath = resolveScenarioPath(scenarioPath, persona.auth.path);
|
||||||
await ensurePrivateDirectory(dirname(outputPath));
|
if (options.delete) {
|
||||||
if (options.importState) {
|
await deleteAuthState(outputPath);
|
||||||
const imported = validateStorageState(
|
|
||||||
JSON.parse(await Deno.readTextFile(resolve(options.importState))),
|
|
||||||
);
|
|
||||||
await writePrivateJson(outputPath, imported);
|
|
||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
if (!persona.login) {
|
await ensurePrivateDirectory(dirname(outputPath));
|
||||||
throw new Error(`persona ${persona.id} needs login configuration or --import-state`);
|
|
||||||
}
|
|
||||||
const baseUrl = validateBaseUrl(
|
const baseUrl = validateBaseUrl(
|
||||||
interpolateEnvironment(
|
interpolateEnvironment(
|
||||||
options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "",
|
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 });
|
const browser = await chromium.launch({ headless: options.headless ?? false });
|
||||||
try {
|
try {
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
@@ -67,7 +76,8 @@ export async function authenticate(options: AuthOptions): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await context.storageState({ path: outputPath });
|
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;
|
return outputPath;
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
@@ -38,8 +38,10 @@ async function waitForReady(url: string, timeoutMs: number): Promise<void> {
|
|||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2_000) });
|
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2_000) });
|
||||||
if (response.status < 500) return;
|
const status = response.status;
|
||||||
lastError = `HTTP ${response.status}`;
|
await response.body?.cancel();
|
||||||
|
if (status < 500) return;
|
||||||
|
lastError = `HTTP ${status}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error instanceof Error ? error.message : String(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