185 lines
7.3 KiB
TypeScript
185 lines
7.3 KiB
TypeScript
/// <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);
|
|
}
|
|
});
|