Files
yoi/web/workspace/test/auth-browser/passkey-state.browser.test.ts
Hare f17b5212c7 fix(auth): confirm passkey operations and preserve safe legacy migration
Fence displayed identity and native prompt generations, classify secret-free outcomes, recover atomic addition receipts without restoring old credentials, and expose truthful own-key verification metadata. Preserve required verifier and enrollment authority boundaries. Include real-store, production browser, native CTAP2 and visible responsive regressions.

Validation: root check/fmt; API 115; passkey security 25; Web unit 712/component 605; auth/entry/activation/hosting browsers; product auth E2E 8 and two explicit browser suites. Server suite: 1262 lib+12 CLI, 3 ignored and 2 environment-specific exclusions. State-name checker retains 15 byte-identical baseline findings; no new exceptions. Physical keys/mobile and hosted CI remain unverified.
2026-10-26 07:18:23 +09:00

209 lines
7.6 KiB
TypeScript

/// <reference lib="dom" />
// 本番shellと可視状態の試験。HTTP/認証器を合成fixtureへ置換するため、暗号/保存の成功根拠ではない。
// native CTAP2 + 実Backend経路はtests/e2eの別suiteが所有する。
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { type Browser, chromium } from "playwright";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
Deno.test("account addition keeps pending and recovery visible at desktop and mobile widths", async (t) => {
const root = resolve(import.meta.dirname!, "../../../..");
const reservation = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (reservation.addr as Deno.NetAddr).port;
reservation.close();
const origin = `http://127.0.0.1:${port}`;
const out = `${root}/target/web-ux/t783-after`;
const processes = await startOwnedProcesses(
[{
id: "passkey-state",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/auth-browser/fixture-server.ts",
String(port),
"web/workspace/build",
],
readyUrl: `${origin}/health`,
}],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
`${out}/processes`,
[],
);
let browser: Browser | undefined;
const records: unknown[] = [];
try {
browser = await chromium.launch();
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const passed = await t.step(
`${theme}/${width}: visible pending, stop and migration metadata`,
async () => {
const context = await browser!.newContext({
viewport: { width, height: 900 },
colorScheme: theme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
page.setDefaultTimeout(5000);
const pageErrors: string[] = [];
const statuses: number[] = [];
const consoleErrors: string[] = [];
const requestErrors: string[] = [];
page.on(
"requestfailed",
() => requestErrors.push("request-failed"),
);
page.on("pageerror", () => pageErrors.push("page-error"));
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push("console-error");
}
});
page.on("response", (response) => {
if (response.status() >= 400) statuses.push(response.status());
});
await context.route(
"**/*",
(route) =>
new URL(route.request().url()).origin === origin
? route.continue()
: route.abort(),
);
await page.request.get(`${origin}/?fixture=addition-pending`);
const documentResponse = await page.goto(`${origin}/account`);
const add = page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
});
await add.waitFor();
await page.getByRole("list", { name: "Registered keys" })
.waitFor();
await page.screenshot({
path: `${out}/account-${theme}-${width}.png`,
fullPage: true,
});
await add.click();
const pending = page.getByRole("status").filter({
hasText: "Waiting for passkey addition",
});
await pending.waitFor({ state: "visible" });
const box = await pending.boundingBox();
assert(
box && box.y >= 0 && box.y + box.height <= 900,
"pending must be in the initial visible viewport",
);
assertEquals(await add.isDisabled(), true);
assertEquals(
await page.getByText("Loading session…", { exact: true })
.isVisible(),
false,
);
await page.screenshot({
path: `${out}/pending-${theme}-${width}.png`,
fullPage: true,
});
const pendingAccessibility = await page.locator("main")
.ariaSnapshot();
await page.getByRole("button", {
name: "Stop waiting",
exact: true,
}).click();
await page.evaluate(() =>
(window as unknown as { __authFixtureRelease: () => void })
.__authFixtureRelease()
);
await page.getByRole("alert").filter({
hasText: "Completion was not sent",
}).waitFor();
await page.getByRole("button", {
name: "Refresh registered keys",
exact: true,
}).click();
await page.getByText(
"Not yet verified — storage alone does not prove support",
{ exact: true },
).waitFor();
await page.getByText(
"Verify migration before retiring older-key login",
{ exact: true },
).click();
await page.screenshot({
path: `${out}/migration-${theme}-${width}.png`,
fullPage: true,
});
assertEquals(
await page.evaluate(() =>
document.documentElement.scrollWidth > innerWidth
),
false,
);
assertEquals(documentResponse?.status(), 200);
assertEquals(pageErrors, []);
assertEquals(consoleErrors, []);
assertEquals(statuses, []);
assertEquals(requestErrors, []);
const images = [];
for (
const name of [
`account-${theme}-${width}.png`,
`pending-${theme}-${width}.png`,
`migration-${theme}-${width}.png`,
]
) {
const digest = await crypto.subtle.digest(
"SHA-256",
await Deno.readFile(`${out}/${name}`),
);
images.push({
name,
sha256: Array.from(
new Uint8Array(digest),
(byte) => byte.toString(16).padStart(2, "0"),
).join(""),
});
}
records.push({
route: "/account",
actor: "synthetic Alice",
theme,
width,
documentStatus: documentResponse?.status(),
pageErrors,
consoleErrors,
requestErrors,
statuses,
pendingAccessibility,
visible: await page.locator("main").innerText(),
images,
});
} finally {
await context.close();
}
},
);
if (!passed) {
throw new Error(
"Stop on the first failed visibility condition; investigate before repeating the matrix",
);
}
}
}
} finally {
await browser?.close();
await stopOwnedProcesses(processes);
await Deno.writeTextFile(
`${out}/review-context.json`,
JSON.stringify(records, null, 2),
);
}
});