feat: explicit hosting administrators and persistent self-registration policy
This commit is contained in:
@@ -24,6 +24,7 @@ interface Fixture {
|
||||
workspace_id: string;
|
||||
owner_token: string;
|
||||
second_token: string;
|
||||
foreign_token: string;
|
||||
worker_entry: DriveEntry;
|
||||
}
|
||||
function assert(value: unknown, message: string): asserts value {
|
||||
@@ -164,14 +165,14 @@ try {
|
||||
"revoke must be durably visible",
|
||||
);
|
||||
}
|
||||
globalThis.fetch = authenticatedFetch(fixture.origin, fixture.second_token);
|
||||
globalThis.fetch = authenticatedFetch(fixture.origin, fixture.foreign_token);
|
||||
try {
|
||||
await listDriveGrants(ws);
|
||||
throw new Error("member must not manage grants");
|
||||
throw new Error("foreign Account must not manage grants");
|
||||
} catch (error) {
|
||||
assert(
|
||||
error instanceof DriveGrantError && error.outcome === "not_committed",
|
||||
"member grant read must be a real authorized rejection",
|
||||
"foreign Account grant read must be a real authorized rejection",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -318,7 +319,7 @@ const secondRead = await second.readText(ws, updated.entry);
|
||||
equal(
|
||||
secondRead.entry.last_mutation_id,
|
||||
updated.last_mutation_id,
|
||||
"second actor must observe same last_mutation_id",
|
||||
"second owner client must observe same last_mutation_id",
|
||||
);
|
||||
const drafts = ["# actor A draft\n", "# actor B draft\n"];
|
||||
const race = await Promise.allSettled(
|
||||
@@ -335,7 +336,7 @@ const race = await Promise.allSettled(
|
||||
equal(
|
||||
race.filter((result) => result.status === "fulfilled").length,
|
||||
1,
|
||||
"same-last_mutation_id two-actor CAS must have exactly one winner",
|
||||
"same-last_mutation_id two-client CAS must have exactly one winner",
|
||||
);
|
||||
const loser = race.find((result) => result.status === "rejected");
|
||||
assert(
|
||||
@@ -348,7 +349,7 @@ const latest = await owner.readText(ws, updated.entry);
|
||||
equal(
|
||||
latest.text,
|
||||
drafts[winnerIndex],
|
||||
"durable content must be the winning actor's draft",
|
||||
"durable content must be the winning client's draft",
|
||||
);
|
||||
// This API boundary proves no automatic retry/overwrite. UI dirty-draft
|
||||
// retention is deliberately verified by the separate browser fixture.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Hosting UIのブラウザ検証
|
||||
|
||||
production UIと合成API応答の組合せを確認する。Setupだけはbrowser API境界も`setup-projection.ts`で置換する。Backend認可・実token・暗号署名・native WebAuthnを証明する検証ではない。実sessionやsecretは持ち込まず、request headers/bodyも記録しない。
|
||||
|
||||
共有buildは読取り専用とし、画像・診断・一時領域は`target/web-ux`へ分離する。実行入口と境界の理由は各test/capture sourceに置く。自動検査の成功は、画像とreview contextの目視判定を代替しない。
|
||||
@@ -0,0 +1,113 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import type { ReviewContext } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const output = `${root}/target/web-ux/hosting`;
|
||||
async function sha(bytes: Uint8Array<ArrayBuffer>) {
|
||||
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) =>
|
||||
value.toString(16).padStart(2, "0")
|
||||
).join("");
|
||||
}
|
||||
const results = [];
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (const prefix of ["before", "after", "navigation-before", "navigation-after"]) {
|
||||
const run = `${prefix}-final-${theme}`;
|
||||
const context: ReviewContext = JSON.parse(
|
||||
await Deno.readTextFile(`${output}/${run}/review-context.json`),
|
||||
);
|
||||
assertEquals(context.diagnostics, []);
|
||||
assert(!context.diagnosticSummary.truncated);
|
||||
let screenshots = 0, expectedFailureCaptures = 0;
|
||||
for (const capture of context.captures) {
|
||||
assertEquals(capture.document.status, 200);
|
||||
assert(!capture.errorSummary.truncated);
|
||||
const expectedFailure = ["admin-load-error", "admin-save-error", "account-error"].includes(
|
||||
capture.route.id,
|
||||
);
|
||||
if (expectedFailure) {
|
||||
expectedFailureCaptures++;
|
||||
// Non-OK bodies are cancelled by production client; ERR_ABORTED is the
|
||||
// same deliberately failed response, not an unrelated network failure.
|
||||
assertEquals(capture.errors.length, 3);
|
||||
assertEquals(
|
||||
capture.errors.filter((error) => error.status === 503).length,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
capture.errors.filter((error) =>
|
||||
error.kind === "console" && error.message.includes("503")
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
capture.errors.filter((error) =>
|
||||
error.kind === "request" && error.message === "net::ERR_ABORTED"
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
} else assertEquals(capture.errors, []);
|
||||
assert(capture.snapshot !== null);
|
||||
const snapshot = await Deno.readTextFile(`${output}/${run}/${capture.snapshot.bundlePath}`);
|
||||
if (["ordinary", "anonymous"].includes(capture.route.id)) {
|
||||
assert(!snapshot.includes('button "Edit registration policy"'));
|
||||
assert(!snapshot.includes('link "Hosting settings"'));
|
||||
}
|
||||
if (capture.route.id === "ordinary-account-off") {
|
||||
assert(snapshot.includes('button "Add passkey to alice"'));
|
||||
assert(!snapshot.includes('link "Hosting settings"'));
|
||||
}
|
||||
for (const screenshot of capture.screenshots) {
|
||||
assertEquals(
|
||||
await sha(await Deno.readFile(`${output}/${run}/${screenshot.bundlePath}`)),
|
||||
screenshot.sha256,
|
||||
);
|
||||
screenshots++;
|
||||
}
|
||||
}
|
||||
assertEquals(expectedFailureCaptures, prefix === "after" ? 9 : 0);
|
||||
results.push({
|
||||
run,
|
||||
captures: context.captures.length,
|
||||
screenshots,
|
||||
expectedFailureCaptures,
|
||||
allDocuments200: true,
|
||||
unexpectedErrors: 0,
|
||||
imageHashesVerified: true,
|
||||
diagnosticTruncation: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
async function inventory(directory: string): Promise<{ path: string; sha256: string }[]> {
|
||||
const files = [];
|
||||
for await (const entry of Deno.readDir(directory)) {
|
||||
const path = `${directory}/${entry.name}`;
|
||||
if (entry.isDirectory) files.push(...await inventory(path));
|
||||
else if (entry.isFile) {
|
||||
files.push({
|
||||
path: path.slice(root.length + 1),
|
||||
sha256: await sha(await Deno.readFile(path)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return files.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
const baselineAssets = await inventory(`${root}/target/web-ux/baseline-source/web/workspace/build`);
|
||||
const currentAssets = await inventory(`${root}/web/workspace/build`);
|
||||
await Deno.writeTextFile(
|
||||
`${output}/evidence-audit.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
boundary:
|
||||
"Actual production static shells; synthetic HTTP only. No Backend authorization/persistence/WebAuthn evidence.",
|
||||
baselineSource: "git archive 7042ec7d50ca8b03b8034c33e9792f4b6ac9d48d web/workspace",
|
||||
currentSource:
|
||||
"Dirty shared worktree at same HEAD; exact served build bytes identified by inventory. Parent owns source/build provenance and final visual verdict.",
|
||||
results,
|
||||
baselineAssets,
|
||||
currentAssets,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
@@ -0,0 +1,142 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import type { ReviewContext } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const output = `${root}/target/web-ux/hosting`;
|
||||
const synthetic = `ui-projection-${crypto.randomUUID()}`;
|
||||
// 合成値はこの子processのmemory/envに限定する。argv、URL、保存証拠へ出さない。
|
||||
await Deno.mkdir(`${root}/target/web-ux/tmp`, { recursive: true });
|
||||
const audit = [];
|
||||
const includeExisting = Deno.args.includes("--existing");
|
||||
const runLabel = Deno.args.find((arg) => arg.startsWith("--run-label="))?.slice(12) ??
|
||||
String(Date.now());
|
||||
assert(/^[a-z0-9-]+$/.test(runLabel), "run label must be a safe path component");
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (
|
||||
const kind of includeExisting ? ["setup", "settings"] : ["setup"]
|
||||
) {
|
||||
const run = `${kind === "setup" ? "setup" : "after-setup"}-${runLabel}-${theme}`;
|
||||
const child = await new Deno.Command(Deno.execPath(), {
|
||||
cwd: root,
|
||||
env: { TMPDIR: `${root}/target/web-ux/tmp`, WEB_UX_SYNTHETIC_SETUP_INPUT: synthetic },
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-env",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"--allow-write=target/web-ux",
|
||||
"--allow-run",
|
||||
"--allow-sys",
|
||||
"tools/web-ux/cli.ts",
|
||||
"capture",
|
||||
"--scenario",
|
||||
`tools/web-ux/scenarios/hosting-${kind}-${theme}.json`,
|
||||
"--output",
|
||||
"target/web-ux/hosting",
|
||||
"--run-id",
|
||||
run,
|
||||
],
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}).output();
|
||||
assertEquals(child.code, 2); // 両scenarioとも意図した拒否・障害表示を含む。
|
||||
const context: ReviewContext = JSON.parse(
|
||||
await Deno.readTextFile(`${output}/${run}/review-context.json`),
|
||||
);
|
||||
assertEquals(context.diagnostics, []);
|
||||
assertEquals(context.status, "completed-with-errors");
|
||||
assertEquals(context.captures.length, kind === "setup" ? 18 : 54);
|
||||
let images = 0;
|
||||
for (const capture of context.captures) {
|
||||
assertEquals(capture.document.status, 200);
|
||||
assertEquals(capture.errorSummary.truncated, false);
|
||||
const rejected = capture.route.id === "setup-reject";
|
||||
const expected = rejected ||
|
||||
["admin-load-error", "admin-save-error", "account-error"].includes(capture.route.id);
|
||||
if (expected) {
|
||||
assert(capture.errors.every((error) =>
|
||||
error.status === (rejected ? 403 : 503) ||
|
||||
(error.kind === "console" && error.message.includes(String(rejected ? 403 : 503))) ||
|
||||
(error.kind === "request" && error.message === "net::ERR_ABORTED")
|
||||
));
|
||||
assert(capture.errors.some((error) => error.status === (rejected ? 403 : 503)));
|
||||
} else assertEquals(capture.errors, []);
|
||||
for (const image of capture.screenshots) {
|
||||
assertEquals(
|
||||
await sha(await Deno.readFile(`${output}/${run}/${image.bundlePath}`)),
|
||||
image.sha256,
|
||||
);
|
||||
images++;
|
||||
}
|
||||
}
|
||||
let scannedTextFiles = 0;
|
||||
for await (const path of files(`${output}/${run}`)) {
|
||||
if (/\.(json|md|html|log)$/.test(path)) {
|
||||
assert(
|
||||
!(await Deno.readTextFile(path)).includes(synthetic),
|
||||
"synthetic input must not appear in artifacts",
|
||||
);
|
||||
scannedTextFiles++;
|
||||
}
|
||||
}
|
||||
audit.push({
|
||||
run,
|
||||
source: context.source,
|
||||
captures: context.captures.length,
|
||||
images,
|
||||
unexpectedErrors: 0,
|
||||
allDocuments200: true,
|
||||
imageHashesVerified: true,
|
||||
syntheticInputAbsent: true,
|
||||
scannedTextFiles,
|
||||
});
|
||||
}
|
||||
}
|
||||
async function sha(bytes: Uint8Array<ArrayBuffer>) {
|
||||
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) =>
|
||||
value.toString(16).padStart(2, "0")
|
||||
).join("");
|
||||
}
|
||||
async function* files(dir: string): AsyncGenerator<string> {
|
||||
for await (const entry of Deno.readDir(dir)) {
|
||||
const path = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory) yield* files(path);
|
||||
else if (entry.isFile) yield path;
|
||||
}
|
||||
}
|
||||
async function inventory(dir: string) {
|
||||
const result = [];
|
||||
for await (const path of files(dir)) {
|
||||
result.push({
|
||||
path: path.slice(root.length + 1),
|
||||
sha256: await sha(await Deno.readFile(path)),
|
||||
});
|
||||
}
|
||||
return result.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
const diff = await new Deno.Command("git", {
|
||||
cwd: root,
|
||||
args: ["diff", "--binary", "HEAD", "--", "web/workspace/src", "web/workspace/deno.json"],
|
||||
stdout: "piped",
|
||||
stderr: "null",
|
||||
}).output();
|
||||
assert(diff.success);
|
||||
await Deno.writeTextFile(
|
||||
`${output}/setup-evidence-audit.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
boundary:
|
||||
"Production UI modules/styles unchanged. Setup-only bootstrap replaces navigator.credentials create/get; synthetic API responses. Not native WebAuthn, real token proof, authorization or persistence evidence. No request header/body logging.",
|
||||
audit,
|
||||
currentTrackedWebDiffSha256: await sha(diff.stdout),
|
||||
currentSourceFiles: await inventory(`${root}/web/workspace/src`),
|
||||
currentBuildFiles: await inventory(`${root}/web/workspace/build`),
|
||||
fixtureFiles: await inventory(import.meta.dirname!),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log("Capture diagnostics, image hashes and synthetic-input artifact scan PASS.");
|
||||
@@ -0,0 +1,111 @@
|
||||
// UI projection fixture ONLY: actual production assets, synthetic API boundary.
|
||||
// No real sessions/accounts/DB or Backend authorization claims. Cookie is a
|
||||
// public fixture selector, not a credential; it is never saved as auth state.
|
||||
import { extname, join, resolve } from "@std/path";
|
||||
import { setupProjectionResponse } from "./setup-projection.ts";
|
||||
const [port, build] = Deno.args;
|
||||
if (!port || !build) throw new Error("usage: fixture-server.ts <port> <build-root>");
|
||||
const buildRoot = resolve(build);
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".woff2": "font/woff2",
|
||||
".png": "image/png",
|
||||
};
|
||||
const policies = new Map<string, boolean>();
|
||||
// 各fixture processはscenarioを逐次実行する。setupのBearer-only要求はcookieを
|
||||
// 送らないので、秘密やheadersを読まず直前の合成page条件で応答を選ぶ。
|
||||
let setupMode: string | null = null;
|
||||
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
const selected = url.searchParams.get("fixture");
|
||||
if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) setupMode = selected;
|
||||
const mode = selected ?? request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ??
|
||||
(url.pathname.startsWith("/api/auth/passkeys/addition/") ? setupMode : null) ?? "admin-on";
|
||||
if (selected) policies.delete(mode);
|
||||
const setup = setupProjectionResponse(url.pathname, mode, selected !== null);
|
||||
if (setup) return setup;
|
||||
const anonymous = mode.startsWith("anonymous") || mode.startsWith("account-");
|
||||
const ordinary = mode.startsWith("ordinary");
|
||||
if (url.pathname === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: anonymous ? null : {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/workspaces") return Response.json([]);
|
||||
if (url.pathname === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: !ordinary && !anonymous });
|
||||
}
|
||||
const policy = () => ({ self_registration_enabled: policies.get(mode) ?? !mode.includes("off") });
|
||||
if (url.pathname === "/api/auth/registration-policy") {
|
||||
if (mode === "account-error") {
|
||||
return new Response("synthetic availability failure", { status: 503 });
|
||||
}
|
||||
if (mode === "account-loading") return new Promise<Response>(() => {});
|
||||
return Response.json(policy());
|
||||
}
|
||||
if (url.pathname === "/api/hosting/settings") {
|
||||
if (anonymous || ordinary) {
|
||||
return new Response("synthetic denied", { status: anonymous ? 401 : 403 });
|
||||
}
|
||||
if (mode === "admin-load-error") {
|
||||
return new Response("synthetic settings failure", { status: 503 });
|
||||
}
|
||||
if (mode === "admin-loading" || (mode === "admin-saving" && request.method === "PUT")) {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
if (mode === "admin-save-error") {
|
||||
return new Response("synthetic unconfirmed save", { status: 503 });
|
||||
}
|
||||
const data = await request.json();
|
||||
if (typeof data.self_registration_enabled !== "boolean") {
|
||||
return new Response("invalid", { status: 400 });
|
||||
}
|
||||
policies.set(mode, data.self_registration_enabled);
|
||||
}
|
||||
return Response.json(policy());
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return new Response("unexpected fixture API", { status: 404 });
|
||||
}
|
||||
const path = join(buildRoot, decodeURIComponent(url.pathname));
|
||||
if (!path.startsWith(buildRoot + "/")) return new Response("invalid path", { status: 400 });
|
||||
let bytes: Uint8Array;
|
||||
let extension = extname(path);
|
||||
try {
|
||||
bytes = await Deno.readFile(path);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound) && !(error instanceof Deno.errors.IsADirectory)) {
|
||||
throw error;
|
||||
}
|
||||
bytes = await Deno.readFile(join(buildRoot, "index.html"));
|
||||
extension = ".html";
|
||||
}
|
||||
// productionのmarkup/style/moduleを保ち、Setupだけbrowser API境界を代替する。
|
||||
// このbootstrapはnavigator.credentialsのみを置換し、formや表示を操作しない。
|
||||
if (extension === ".html" && mode.startsWith("setup-")) {
|
||||
bytes = new TextEncoder().encode(
|
||||
new TextDecoder().decode(bytes).replace(
|
||||
"<head>",
|
||||
'<head><script src="/__ui_fixture/webauthn.js"></script>',
|
||||
),
|
||||
);
|
||||
}
|
||||
const headers = new Headers({
|
||||
"content-type": mime[extension] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
if (selected) headers.set("set-cookie", `ui-fixture=${selected}; Path=/; SameSite=Strict`);
|
||||
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { resolve } from "@std/path";
|
||||
import type { Scenario } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const scenario: Scenario = JSON.parse(
|
||||
await Deno.readTextFile(`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`),
|
||||
);
|
||||
scenario.id = `hosting-baseline-${theme}`;
|
||||
scenario.title = "Before: tracked HEAD Account and global navigation; no hosting route existed";
|
||||
scenario.routes = [
|
||||
{
|
||||
id: "account-on",
|
||||
label: "Anonymous Account before",
|
||||
path: "/account?fixture=account-on",
|
||||
goal: "Compare existing Account registration/login before policy gating.",
|
||||
dataState: "Synthetic anonymous session; unchanged baseline production shell.",
|
||||
ready: { kind: "selector", selector: 'button:has-text("Register new account"):enabled' },
|
||||
capturePoints: [{ id: "initial", label: "Before anonymous Account" }],
|
||||
},
|
||||
{
|
||||
id: "admin-account",
|
||||
label: "Signed-in Account before",
|
||||
path: "/account?fixture=admin-account",
|
||||
goal: "Compare Account and global navigation before admin settings link.",
|
||||
dataState: "Synthetic signed-in session; no hosting route/link before.",
|
||||
ready: { kind: "selector", selector: 'button:has-text("Add passkey to alice")' },
|
||||
capturePoints: [{ id: "initial", label: "Before signed-in Account" }],
|
||||
},
|
||||
];
|
||||
scenario.processes![0].cwd = root;
|
||||
scenario.processes![0].args![scenario.processes![0].args!.length - 1] =
|
||||
"target/web-ux/baseline-source/web/workspace/build";
|
||||
for (const route of scenario.routes) route.capturePoints[0].fullPage = true;
|
||||
await Deno.writeTextFile(
|
||||
`${root}/target/web-ux/baseline-${theme}.json`,
|
||||
JSON.stringify(scenario, null, 2),
|
||||
);
|
||||
for (const version of ["before", "after"]) {
|
||||
const nav = structuredClone(scenario);
|
||||
nav.id = `hosting-navigation-${version}-${theme}`;
|
||||
nav.title = `${version} mobile global navigation on production shell`;
|
||||
nav.viewports = nav.viewports.filter((item) => item.width === 390);
|
||||
for (const route of nav.routes) {
|
||||
route.capturePoints = [{
|
||||
id: "navigation",
|
||||
label: "Mobile global links",
|
||||
interaction: [{ action: "click", selector: 'button[aria-label="Show sidebar"]' }],
|
||||
ready: { kind: "selector", selector: 'nav[aria-label="Global pages"]' },
|
||||
}];
|
||||
}
|
||||
if (version === "after") {
|
||||
nav.processes![0].args![nav.processes![0].args!.length - 1] = "web/workspace/build";
|
||||
}
|
||||
await Deno.writeTextFile(
|
||||
`${root}/target/web-ux/navigation-${version}-${theme}.json`,
|
||||
JSON.stringify(nav, null, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { resolve } from "@std/path";
|
||||
import type { RouteScenario, Scenario } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const selector = (selector: string) => ({ kind: "selector" as const, selector });
|
||||
const click = (selector: string) => ({ action: "click" as const, selector });
|
||||
const press = (selector: string, key: string) => ({ action: "press" as const, selector, key });
|
||||
const route = (
|
||||
id: string,
|
||||
path: string,
|
||||
ready: string,
|
||||
dataState: string,
|
||||
points = [{ id: "initial", label: "Initial state" }],
|
||||
): RouteScenario => ({
|
||||
id,
|
||||
label: id,
|
||||
path: `${path}?fixture=${id}`,
|
||||
goal: "Verify production shell UI projection, not server authorization.",
|
||||
dataState,
|
||||
ready: selector(ready),
|
||||
capturePoints: points,
|
||||
});
|
||||
const routes: RouteScenario[] = [
|
||||
...["on", "off"].map((policy) =>
|
||||
route(
|
||||
`admin-${policy}`,
|
||||
"/hosting/settings",
|
||||
'button:has-text("Edit registration policy")',
|
||||
`Synthetic admin; policy ${policy}.`,
|
||||
[
|
||||
{ id: "initial", label: "Loaded hosting-wide policy" },
|
||||
{
|
||||
id: "edit",
|
||||
label: "Keyboard edit focus",
|
||||
interaction: [press('button:has-text("Edit registration policy")', "Enter")],
|
||||
ready: selector('input[type="checkbox"]:focus'),
|
||||
},
|
||||
{
|
||||
id: "saved",
|
||||
label: "Confirmed save",
|
||||
interaction: [
|
||||
press('input[type="checkbox"]', "Space"),
|
||||
click('button:has-text("Save settings")'),
|
||||
],
|
||||
ready: selector('[role="status"]:has-text("Hosting settings saved.")'),
|
||||
},
|
||||
] as RouteScenario["capturePoints"],
|
||||
)
|
||||
),
|
||||
route(
|
||||
"ordinary",
|
||||
"/hosting/settings",
|
||||
'p:has-text("Hosting administrator access is required.")',
|
||||
"Synthetic authenticated ordinary user, no admin controls.",
|
||||
),
|
||||
route(
|
||||
"anonymous",
|
||||
"/hosting/settings",
|
||||
'a:has-text("Open Account")',
|
||||
"Synthetic anonymous visitor; sign-in guidance, no controls.",
|
||||
),
|
||||
route(
|
||||
"admin-loading",
|
||||
"/hosting/settings",
|
||||
'[role="status"]:has-text("Loading hosting settings")',
|
||||
"Settings GET intentionally held pending; shell remains usable.",
|
||||
),
|
||||
route(
|
||||
"admin-load-error",
|
||||
"/hosting/settings",
|
||||
'button:has-text("Retry loading settings")',
|
||||
"Intentional HTTP503; expected console resource error and visible alert.",
|
||||
),
|
||||
...["saving", "save-error"].map((state) =>
|
||||
route(
|
||||
`admin-${state}`,
|
||||
"/hosting/settings",
|
||||
'button:has-text("Edit registration policy")',
|
||||
state === "saving"
|
||||
? "PUT held pending; last loaded policy retained, edit controls disabled."
|
||||
: "Intentional PUT HTTP503; outcome unknown, retry blocked until reload.",
|
||||
[
|
||||
{
|
||||
id: state,
|
||||
label: state,
|
||||
interaction: [
|
||||
click('button:has-text("Edit registration policy")'),
|
||||
press('input[type="checkbox"]', "Space"),
|
||||
click('button:has-text("Save settings")'),
|
||||
],
|
||||
ready: selector(state === "saving" ? 'button:has-text("Saving…")' : '[role="alert"]'),
|
||||
},
|
||||
] as RouteScenario["capturePoints"],
|
||||
)
|
||||
),
|
||||
route(
|
||||
"account-on",
|
||||
"/account",
|
||||
'button:has-text("Register new account"):enabled',
|
||||
"Anonymous policy on; registration and login available.",
|
||||
),
|
||||
route(
|
||||
"account-off",
|
||||
"/account",
|
||||
'p:has-text("New account registration is disabled.")',
|
||||
"Anonymous policy off; no registration action, login available.",
|
||||
),
|
||||
route(
|
||||
"admin-account",
|
||||
"/account",
|
||||
'button:has-text("Add passkey to alice")',
|
||||
"Authenticated admin; own passkey addition and global settings link.",
|
||||
),
|
||||
route(
|
||||
"ordinary-account-off",
|
||||
"/account",
|
||||
'button:has-text("Add passkey to alice")',
|
||||
"Authenticated ordinary user with policy off; own passkey addition available without admin nav.",
|
||||
),
|
||||
route(
|
||||
"account-error",
|
||||
"/account",
|
||||
'button:has-text("Retry registration availability")',
|
||||
"Intentional policy HTTP503; expected console resource error and visible alert; login retained.",
|
||||
),
|
||||
route(
|
||||
"account-loading",
|
||||
"/account",
|
||||
'[role="status"]:has-text("Checking registration availability")',
|
||||
"Policy request held pending; login remains available after session loads.",
|
||||
),
|
||||
];
|
||||
for (const account of routes.filter((item) => item.path.startsWith("/account"))) {
|
||||
account.capturePoints[0].fullPage = true;
|
||||
}
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
const scenario: Scenario = {
|
||||
schemaVersion: 1,
|
||||
id: `hosting-settings-${theme}`,
|
||||
title: "Hosting settings: synthetic UI conditions on production shell",
|
||||
baseUrl: "http://127.0.0.1:15378",
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
personas: [{
|
||||
id: "fixture",
|
||||
label: "Synthetic API projection; no real authentication",
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [{ label: "desktop", width: 1440, height: 900 }, {
|
||||
label: "narrow",
|
||||
width: 768,
|
||||
height: 900,
|
||||
}, { label: "mobile", width: 390, height: 844 }],
|
||||
routes,
|
||||
processes: [{
|
||||
id: "hosting-ui-fixture",
|
||||
command: "deno",
|
||||
cwd: "../../..",
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15378",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15378/health",
|
||||
}],
|
||||
};
|
||||
await Deno.writeTextFile(
|
||||
`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`,
|
||||
JSON.stringify(scenario, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { resolve } from "@std/path";
|
||||
import type { Interaction, Scenario } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const submit: Interaction[] = [{
|
||||
action: "fill",
|
||||
selector: "#host-setup-token",
|
||||
value: "${WEB_UX_SYNTHETIC_SETUP_INPUT}",
|
||||
}, { action: "click", selector: 'button:has-text("Add administrator passkey")' }];
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const scenario: Scenario = JSON.parse(
|
||||
await Deno.readTextFile(`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`),
|
||||
);
|
||||
scenario.id = `hosting-setup-${theme}`;
|
||||
scenario.title =
|
||||
"Hosting setup production UI: synthetic HTTP and navigator.credentials boundary only";
|
||||
scenario.redact = { text: ["${WEB_UX_SYNTHETIC_SETUP_INPUT}"] };
|
||||
scenario.routes = [{
|
||||
id: "setup-flow",
|
||||
label: "Setup enrollment and login UI",
|
||||
path: "/hosting/setup?fixture=setup-flow",
|
||||
goal:
|
||||
"Separate host-token enrollment from explicit passkey login and verified settings access.",
|
||||
dataState:
|
||||
"Synthetic actor, API and non-cryptographic navigator.credentials; no real token/authentication. Test-only bootstrap changes browser API boundary, not production UI modules/styles.",
|
||||
ready: { kind: "selector", selector: "#host-setup-token:enabled" },
|
||||
capturePoints: [
|
||||
{ id: "initial", label: "Empty password field; enrollment disabled", fullPage: true },
|
||||
{
|
||||
id: "host-help",
|
||||
label: "Trusted-host instructions expanded",
|
||||
interaction: [{ action: "press", selector: "summary", key: "Enter" }],
|
||||
ready: { kind: "selector", selector: "details[open]" },
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "masked-entry",
|
||||
label: "Synthetic input password-masked; action enabled",
|
||||
interaction: [{ action: "press", selector: "summary", key: "Enter" }, submit[0]],
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: 'button:has-text("Add administrator passkey"):enabled',
|
||||
},
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "enrolled",
|
||||
label: "Enrollment is not login; explicit login handoff",
|
||||
interaction: [submit[1]],
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: 'button:has-text("Sign in with new passkey"):enabled',
|
||||
},
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "verified",
|
||||
label: "Matching actor/session/access projected verified; settings link",
|
||||
interaction: [{
|
||||
action: "press",
|
||||
selector: 'button:has-text("Sign in with new passkey")',
|
||||
key: "Enter",
|
||||
}],
|
||||
ready: { kind: "selector", selector: 'a:has-text("Open hosting settings")' },
|
||||
fullPage: true,
|
||||
},
|
||||
],
|
||||
}, {
|
||||
id: "setup-reject",
|
||||
label: "Rejected setup enrollment",
|
||||
path: "/hosting/setup?fixture=setup-reject",
|
||||
goal:
|
||||
"Show safe unconfirmed-enrollment guidance with empty cleared token and no settings action.",
|
||||
dataState:
|
||||
"Intentional addition/options HTTP403. Expected console/resource diagnostics; no real authorization exercised.",
|
||||
ready: { kind: "selector", selector: "#host-setup-token:enabled" },
|
||||
capturePoints: [{
|
||||
id: "rejected",
|
||||
label: "Rejected attempt; token cleared",
|
||||
interaction: submit,
|
||||
ready: { kind: "selector", selector: '[role="alert"]' },
|
||||
fullPage: true,
|
||||
}],
|
||||
}];
|
||||
await Deno.writeTextFile(
|
||||
`${root}/tools/web-ux/scenarios/hosting-setup-${theme}.json`,
|
||||
JSON.stringify(scenario, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/// <reference lib="dom" />
|
||||
// Production-shell browser behavior with synthetic HTTP projections only.
|
||||
// Run against prebuilt assets; never builds or uses a real session/backend.
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { chromium, type Page } from "playwright";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const evidence = `${root}/target/web-ux/hosting/interactions`;
|
||||
async function noOverflow(page: Page) {
|
||||
assertEquals(
|
||||
await page.evaluate(() => ({
|
||||
page: document.documentElement.scrollWidth > innerWidth,
|
||||
main: [...document.querySelectorAll("main")].some((main) =>
|
||||
main.scrollWidth > main.clientWidth
|
||||
),
|
||||
})),
|
||||
{ page: false, main: false },
|
||||
);
|
||||
}
|
||||
Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown save outcome on production shell", async () => {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "hosting-interactions",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15379",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15379/health",
|
||||
readyTimeoutMs: 10000,
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/hosting-settings-light.json`,
|
||||
evidence,
|
||||
[],
|
||||
);
|
||||
const browser = await chromium.launch();
|
||||
const results: unknown[] = [];
|
||||
try {
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const httpErrors: string[] = [];
|
||||
const failedRequests: { url: string; error: string }[] = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) {
|
||||
httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`);
|
||||
}
|
||||
});
|
||||
page.on(
|
||||
"requestfailed",
|
||||
(request) =>
|
||||
failedRequests.push({
|
||||
url: request.url(),
|
||||
error: request.failure()?.errorText ?? "unknown",
|
||||
}),
|
||||
);
|
||||
const settingsRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname === "/api/hosting/settings") {
|
||||
settingsRequests.push(request.method());
|
||||
}
|
||||
});
|
||||
await page.goto("http://127.0.0.1:15379/account?fixture=admin-on");
|
||||
await page.getByRole("button", { name: "Add passkey to alice" }).waitFor();
|
||||
if (
|
||||
await page.getByRole("button", { name: "Show sidebar", exact: true }).isVisible()
|
||||
) await page.getByRole("button", { name: "Show sidebar", exact: true }).click();
|
||||
const nav = page.getByRole("navigation", { name: "Global pages" });
|
||||
await nav.getByRole("link", { name: "Hosting settings", exact: true }).waitFor();
|
||||
await nav.getByRole("link", { name: "Account", exact: true }).focus();
|
||||
await page.keyboard.press("Tab");
|
||||
assertEquals(await page.locator(":focus").innerText(), "Device Login");
|
||||
await page.keyboard.press("Tab");
|
||||
assertEquals(await page.locator(":focus").innerText(), "Hosting settings");
|
||||
assertEquals(
|
||||
await page.locator(":focus").evaluate((element) =>
|
||||
getComputedStyle(element).outlineStyle !== "none"
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
const edit = page.getByRole("button", { name: "Edit registration policy" });
|
||||
await edit.waitFor();
|
||||
assertEquals(
|
||||
await page.locator('a[href="/hosting/settings"]').getAttribute("aria-current"),
|
||||
"page",
|
||||
);
|
||||
if (
|
||||
await page.getByRole("button", { name: "Hide sidebar", exact: true }).isVisible() &&
|
||||
width < 768
|
||||
) await page.getByRole("button", { name: "Hide sidebar", exact: true }).click();
|
||||
await noOverflow(page);
|
||||
await edit.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const checkbox = page.getByRole("checkbox", { name: "Allow new account registration" });
|
||||
assertEquals(
|
||||
await checkbox.evaluate((element) => element === document.activeElement),
|
||||
true,
|
||||
);
|
||||
assertEquals(await checkbox.isChecked(), true);
|
||||
await page.keyboard.press("Space");
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
assertEquals(await edit.evaluate((element) => element === document.activeElement), true);
|
||||
await page.keyboard.press("Enter");
|
||||
assertEquals(await checkbox.isChecked(), true);
|
||||
await page.keyboard.press("Space");
|
||||
await page.keyboard.press("Tab");
|
||||
assertEquals(await page.locator(":focus").innerText(), "Save settings");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.getByRole("status").filter({ hasText: "Hosting settings saved." }).waitFor();
|
||||
assertEquals(await edit.evaluate((element) => element === document.activeElement), true);
|
||||
await page.getByText("Disabled", { exact: true }).waitFor();
|
||||
assertEquals(settingsRequests.filter((method) => method === "PUT").length, 1);
|
||||
await noOverflow(page);
|
||||
for (const mode of ["ordinary", "anonymous"]) {
|
||||
settingsRequests.length = 0;
|
||||
await page.goto(`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`);
|
||||
await page.getByText(
|
||||
mode === "ordinary"
|
||||
? "Hosting administrator access is required."
|
||||
: "Sign in with a hosting administrator account to view these settings.",
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
assertEquals(await edit.count(), 0);
|
||||
assertEquals(await checkbox.count(), 0);
|
||||
assertEquals(
|
||||
await nav.getByRole("link", { name: "Hosting settings", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(settingsRequests, []);
|
||||
await noOverflow(page);
|
||||
}
|
||||
await page.goto("http://127.0.0.1:15379/account?fixture=account-off");
|
||||
await page.getByText(
|
||||
"New account registration is disabled. Existing users can still log in.",
|
||||
).waitFor();
|
||||
assertEquals(await page.getByRole("button", { name: "Register new account" }).count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Log in with passkey" }).isEnabled(),
|
||||
true,
|
||||
);
|
||||
await noOverflow(page);
|
||||
await page.goto("http://127.0.0.1:15379/account?fixture=ordinary-account-off");
|
||||
await page.getByRole("button", { name: "Add passkey to alice" }).waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Add passkey to alice" }).isEnabled(),
|
||||
true,
|
||||
);
|
||||
await page.goto("http://127.0.0.1:15379/hosting/settings?fixture=admin-save-error");
|
||||
await edit.click();
|
||||
await checkbox.press("Space");
|
||||
await page.getByRole("button", { name: "Save settings", exact: true }).click();
|
||||
await page.getByRole("alert").waitFor();
|
||||
assertEquals(await checkbox.isChecked(), false);
|
||||
assertEquals(await checkbox.isDisabled(), true);
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Save settings", exact: true }).isDisabled(),
|
||||
true,
|
||||
);
|
||||
await noOverflow(page);
|
||||
await page.getByRole("button", { name: "Reload settings", exact: true }).click();
|
||||
await edit.waitFor();
|
||||
assertEquals(await page.getByRole("alert").count(), 0);
|
||||
assertEquals(await edit.isEnabled(), true);
|
||||
for (
|
||||
const [mode, path, ready] of [
|
||||
[
|
||||
"admin-load-error",
|
||||
"/hosting/settings",
|
||||
'button:has-text("Retry loading settings")',
|
||||
],
|
||||
["account-error", "/account", 'button:has-text("Retry registration availability")'],
|
||||
[
|
||||
"admin-loading",
|
||||
"/hosting/settings",
|
||||
'[role="status"]:has-text("Loading hosting settings")',
|
||||
],
|
||||
[
|
||||
"account-loading",
|
||||
"/account",
|
||||
'[role="status"]:has-text("Checking registration availability")',
|
||||
],
|
||||
]
|
||||
) {
|
||||
await page.goto(`http://127.0.0.1:15379${path}?fixture=${mode}`);
|
||||
await page.locator(ready).waitFor();
|
||||
await noOverflow(page);
|
||||
}
|
||||
// Navigation may also cancel the shell's concurrent identity/access
|
||||
// reads. Only an explicit browser cancellation of these reads is expected.
|
||||
assert(
|
||||
failedRequests.every(({ url, error }) =>
|
||||
error === "net::ERR_ABORTED" && [
|
||||
"/api/hosting/settings",
|
||||
"/api/auth/registration-policy",
|
||||
"/api/auth/whoami",
|
||||
"/api/hosting/access",
|
||||
].some((path) => url.endsWith(path))
|
||||
),
|
||||
JSON.stringify(failedRequests),
|
||||
);
|
||||
assertEquals(httpErrors, [
|
||||
"503 /api/hosting/settings",
|
||||
"503 /api/hosting/settings",
|
||||
"503 /api/auth/registration-policy",
|
||||
]);
|
||||
assertEquals(consoleErrors.length, 3);
|
||||
assert(consoleErrors.every((message) => message.includes("503")));
|
||||
assertEquals(errors, []);
|
||||
results.push({
|
||||
theme,
|
||||
width,
|
||||
keyboard: "pass",
|
||||
overflow: "none",
|
||||
deniedControls: "absent",
|
||||
save: "confirmed",
|
||||
unconfirmedSave: "blocked until reload",
|
||||
realAuthorizationTested: false,
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Setupの表示条件だけを代替する。Bearerやrequest bodyは読まず、記録もしない。
|
||||
// WebAuthnは固定の非暗号credentialを返すため、本物の認証・署名検証ではない。
|
||||
export const setupWebAuthnProjection = `
|
||||
(() => {
|
||||
const bytes = () => new Uint8Array([117, 105]).buffer;
|
||||
const core = () => ({ id: 'ui-projection-only', rawId: bytes(), type: 'public-key', getClientExtensionResults: () => ({}) });
|
||||
Object.defineProperty(navigator.credentials, 'create', { configurable: true, value: async () => ({ ...core(), response: { clientDataJSON: bytes(), attestationObject: bytes(), getTransports: () => ['internal'] } }) });
|
||||
Object.defineProperty(navigator.credentials, 'get', { configurable: true, value: async () => ({ ...core(), response: { clientDataJSON: bytes(), authenticatorData: bytes(), signature: bytes(), userHandle: null } }) });
|
||||
})();
|
||||
`;
|
||||
const user = {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
};
|
||||
const stages = new Map<string, "enrolled" | "login">();
|
||||
export function setupProjectionResponse(
|
||||
path: string,
|
||||
mode: string,
|
||||
reset: boolean,
|
||||
): Response | null {
|
||||
if (!mode.startsWith("setup-")) return null;
|
||||
if (reset) stages.delete(mode);
|
||||
if (path === "/__ui_fixture/webauthn.js") {
|
||||
return new Response(setupWebAuthnProjection, {
|
||||
headers: { "content-type": "text/javascript" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: stages.get(mode) === "login" ? { ...user, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: stages.get(mode) === "login" });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/options") {
|
||||
if (mode === "setup-reject") {
|
||||
return new Response("synthetic enrollment rejection", { status: 403 });
|
||||
}
|
||||
return Response.json({
|
||||
challenge_id: "fixture-add",
|
||||
public_key: {
|
||||
challenge: "dWk",
|
||||
rp: { name: "UI projection" },
|
||||
user: { id: "dWk", name: "alice", displayName: "Alice" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/complete") {
|
||||
stages.set(mode, "enrolled");
|
||||
return Response.json({ user });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json({ challenge_id: "fixture-login", public_key: { challenge: "dWk" } });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
stages.set(mode, "login");
|
||||
return Response.json({ user });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/// <reference lib="dom" />
|
||||
// UI projectionのみ。実APIのBearer認可・WebAuthn・cookie発行は検証しない。
|
||||
// requestを観測する場合もmethod/pathだけとし、headers/bodyは取得・保存しない。
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { chromium, type Page } from "playwright";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const evidence = `${root}/target/web-ux/hosting/setup-interactions`;
|
||||
async function noOverflow(page: Page) {
|
||||
assertEquals(
|
||||
await page.evaluate(() => ({
|
||||
page: document.documentElement.scrollWidth > innerWidth,
|
||||
main: [...document.querySelectorAll("main")].some((main) =>
|
||||
main.scrollWidth > main.clientWidth
|
||||
),
|
||||
})),
|
||||
{ page: false, main: false },
|
||||
);
|
||||
}
|
||||
Deno.test("setup UI separates masked host-token enrollment, rejected attempts, explicit login and verified settings navigation", async () => {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "setup-interactions",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15380",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15380/health",
|
||||
readyTimeoutMs: 10000,
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/hosting-setup-light.json`,
|
||||
evidence,
|
||||
[],
|
||||
);
|
||||
const browser = await chromium.launch();
|
||||
const results = [];
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const pageErrors: string[] = [],
|
||||
consoleErrors: string[] = [],
|
||||
httpErrors: string[] = [],
|
||||
requestFailures: string[] = [];
|
||||
const methodsAndPaths: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error)));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) {
|
||||
httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`);
|
||||
}
|
||||
});
|
||||
page.on(
|
||||
"requestfailed",
|
||||
(request) => requestFailures.push(new URL(request.url()).pathname),
|
||||
);
|
||||
page.on(
|
||||
"request",
|
||||
(request) =>
|
||||
methodsAndPaths.push(`${request.method()} ${new URL(request.url()).pathname}`),
|
||||
);
|
||||
await page.goto("http://127.0.0.1:15380/hosting/setup?fixture=setup-flow");
|
||||
const input = page.locator("#host-setup-token");
|
||||
await input.waitFor();
|
||||
const enroll = page.getByRole("button", {
|
||||
name: "Add administrator passkey",
|
||||
exact: true,
|
||||
});
|
||||
assertEquals(await input.getAttribute("type"), "password");
|
||||
assertEquals(await enroll.isDisabled(), true);
|
||||
assert(
|
||||
!methodsAndPaths.some((entry) => entry.startsWith("POST ")),
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Open hosting settings", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await noOverflow(page);
|
||||
await page.locator("summary").focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.locator("details[open]").waitFor();
|
||||
await noOverflow(page);
|
||||
await page.keyboard.press("Enter");
|
||||
const synthetic = `ui-projection-${crypto.randomUUID()}`;
|
||||
await input.fill(synthetic);
|
||||
assertEquals(await enroll.isEnabled(), true);
|
||||
await input.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
assertEquals(
|
||||
await enroll.evaluate((element) => element === document.activeElement),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
await enroll.evaluate((element) => getComputedStyle(element).outlineStyle !== "none"),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
const login = page.getByRole("button", { name: "Sign in with new passkey", exact: true });
|
||||
await login.waitFor();
|
||||
await page.getByRole("status").filter({ hasText: "Enrollment is not a passkey login." })
|
||||
.waitFor();
|
||||
assertEquals(await input.count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Open hosting settings", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
methodsAndPaths.filter((entry) => entry.startsWith("POST ")),
|
||||
[
|
||||
"POST /api/auth/passkeys/addition/options",
|
||||
"POST /api/auth/passkeys/addition/complete",
|
||||
],
|
||||
);
|
||||
await noOverflow(page);
|
||||
await login.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const settings = page.getByRole("link", { name: "Open hosting settings", exact: true });
|
||||
await settings.waitFor();
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Passkey login and hosting administrator access verified.",
|
||||
}).waitFor();
|
||||
await noOverflow(page);
|
||||
await settings.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.getByRole("button", { name: "Edit registration policy", exact: true })
|
||||
.waitFor();
|
||||
await noOverflow(page);
|
||||
await page.goto("http://127.0.0.1:15380/hosting/setup?fixture=setup-reject");
|
||||
await input.fill(synthetic);
|
||||
await enroll.click();
|
||||
const error = page.getByRole("alert");
|
||||
await error.waitFor();
|
||||
assert(!(await input.inputValue()));
|
||||
assertEquals(await enroll.isDisabled(), true);
|
||||
assert(!(await error.innerText()).includes(synthetic));
|
||||
assertEquals(await settings.count(), 0);
|
||||
await noOverflow(page);
|
||||
assertEquals(pageErrors, []);
|
||||
assertEquals(httpErrors, ["403 /api/auth/passkeys/addition/options"]);
|
||||
assertEquals(consoleErrors.length, 1);
|
||||
assert(consoleErrors[0].includes("403"));
|
||||
assert(requestFailures.every((path) => path === "/api/auth/passkeys/addition/options"));
|
||||
results.push({
|
||||
width,
|
||||
theme: colorScheme,
|
||||
emptyDisabled: true,
|
||||
passwordMasked: true,
|
||||
keyboard: "pass",
|
||||
tokenClearedAfterRejectedAttempt: true,
|
||||
enrollmentNotLogin: true,
|
||||
explicitLoginBeforeSettings: true,
|
||||
overflow: "none",
|
||||
unexpectedErrors: 0,
|
||||
requestHeadersOrBodiesRecorded: false,
|
||||
realAuthenticationTested: false,
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user