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.
888 lines
32 KiB
TypeScript
888 lines
32 KiB
TypeScript
/// <reference lib="dom" />
|
|
// 本番shell/router/clientを合成HTTP/WebAuthnで接続する。Server認証/DBのE2Eではない。
|
|
import { assert, assertEquals } from "@std/assert";
|
|
import { resolve } from "@std/path";
|
|
import { type Browser, chromium, type Page } from "playwright";
|
|
import { sessionNavigation } from "./session-navigation.ts";
|
|
import {
|
|
startOwnedProcesses,
|
|
stopOwnedProcesses,
|
|
} from "../../../../tools/web-ux/src/processes.ts";
|
|
|
|
const root = resolve(import.meta.dirname!, "../../../..");
|
|
const origin = "http://127.0.0.1:15391";
|
|
type Mutation = { path: string; keys: string[] };
|
|
type Harness = {
|
|
page: Page;
|
|
mutations: Mutation[];
|
|
go: (path: string, mode?: string, unavailableRoot?: boolean) => Promise<void>;
|
|
};
|
|
const login = async (page: Page) => {
|
|
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
|
.click();
|
|
};
|
|
const legacyLogin = async (page: Page) => {
|
|
await page.getByText("Use an older key with account ID", { exact: true })
|
|
.click();
|
|
await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice");
|
|
await page.getByRole("button", { name: "Log in with older key", exact: true })
|
|
.click();
|
|
};
|
|
const register = async (page: Page) => {
|
|
await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice");
|
|
await page.getByLabel("Display name", { exact: true }).fill("Alice");
|
|
await page.getByRole("button", { name: "Register new account", exact: true })
|
|
.click();
|
|
};
|
|
const additionConfirmed = (page: Page) =>
|
|
page.getByRole("status").filter({
|
|
hasText:
|
|
/^Passkey added to your account; the expected session was confirmed\.$/,
|
|
});
|
|
const count = (mutations: Mutation[], path: string) =>
|
|
mutations.filter((entry) => entry.path === path).length;
|
|
const at = async (page: Page, path: string) => {
|
|
await page.waitForURL((url) =>
|
|
url.origin === origin && url.pathname + url.search === path
|
|
);
|
|
};
|
|
const noOverflow = async (page: Page) =>
|
|
assertEquals(
|
|
await page.evaluate(() =>
|
|
document.documentElement.scrollWidth > innerWidth
|
|
),
|
|
false,
|
|
);
|
|
async function isolated(
|
|
browser: Browser,
|
|
body: (harness: Harness) => Promise<void>,
|
|
colorScheme: "light" | "dark" = "light",
|
|
width = 1440,
|
|
) {
|
|
const context = await browser.newContext({
|
|
viewport: { width, height: 900 },
|
|
colorScheme,
|
|
reducedMotion: "reduce",
|
|
});
|
|
try {
|
|
// 不正returnToでも外部へ接続しない。遷移した事実は下のURL assertionで失敗する。
|
|
await context.route("**/*", (route) => {
|
|
const url = new URL(route.request().url());
|
|
return url.origin === origin ? route.continue() : route.abort();
|
|
});
|
|
const page = await context.newPage();
|
|
const mutations: Mutation[] = [];
|
|
const errors: string[] = [];
|
|
const rejectedLoads: unknown[] = [];
|
|
let expectsUnavailableRoot = false;
|
|
await page.exposeFunction(
|
|
"__authFixtureRejectedLoad",
|
|
(reason: unknown) => rejectedLoads.push(reason),
|
|
);
|
|
await page.addInitScript(() => {
|
|
window.addEventListener("unhandledrejection", (event) => {
|
|
void (window as unknown as {
|
|
__authFixtureRejectedLoad: (reason: unknown) => Promise<void>;
|
|
}).__authFixtureRejectedLoad(event.reason);
|
|
});
|
|
});
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
page.on("request", (request) => {
|
|
const path = new URL(request.url()).pathname;
|
|
if (request.method() === "POST" && path.startsWith("/api/")) {
|
|
// credential本文/secretは保持せず、対象を自由指定しない契約のfield名だけを観測する。
|
|
mutations.push({
|
|
path,
|
|
keys: Object.keys(request.postDataJSON() ?? {}).sort(),
|
|
});
|
|
}
|
|
});
|
|
await body({
|
|
page,
|
|
mutations,
|
|
go: async (path, mode = "anonymous", unavailableRoot = false) => {
|
|
// fixture選択を実際の戻り先queryへ混入させない。同じcontextのcookieをHTTPで設定する。
|
|
expectsUnavailableRoot ||= unavailableRoot ||
|
|
(path.startsWith("/account") &&
|
|
[
|
|
"session-error",
|
|
"session-malformed",
|
|
"session-retry",
|
|
"network-error",
|
|
].includes(mode));
|
|
const setup = new URL("/", origin);
|
|
setup.searchParams.set("fixture", mode);
|
|
await page.request.get(setup.href);
|
|
await page.goto(new URL(path, origin).href);
|
|
},
|
|
});
|
|
await noOverflow(page);
|
|
// root load自身の503ではKitのstatic fallback再実行も同じHttpErrorをrejectする。
|
|
// minifyされたclass名で除外せず、意図した障害fixtureのstatus/bodyと件数だけを許す。
|
|
if (expectsUnavailableRoot && rejectedLoads.length > 0) {
|
|
for (const reason of rejectedLoads) {
|
|
assertEquals(reason, {
|
|
status: 503,
|
|
body: {
|
|
status: 503,
|
|
message:
|
|
"Your session could not be loaded. Login status is unknown. Retry this page.",
|
|
},
|
|
});
|
|
}
|
|
assertEquals(errors.length, rejectedLoads.length);
|
|
} else assertEquals(errors, []);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
Deno.test("standalone auth separates login, registration, own account and explicit device approval", async (t) => {
|
|
const processes = await startOwnedProcesses(
|
|
[{
|
|
id: "auth-browser",
|
|
command: "deno",
|
|
cwd: root,
|
|
args: [
|
|
"run",
|
|
"--config",
|
|
"tools/web-ux/deno.json",
|
|
"--allow-read",
|
|
"--allow-net",
|
|
"web/workspace/test/auth-browser/fixture-server.ts",
|
|
"15391",
|
|
"web/workspace/build",
|
|
],
|
|
readyUrl: `${origin}/health`,
|
|
}],
|
|
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
|
|
`${root}/target/t783/web-shell-final/processes`,
|
|
[],
|
|
);
|
|
let browser: Browser | undefined;
|
|
try {
|
|
browser = await chromium.launch();
|
|
await sessionNavigation(t, browser, isolated, origin);
|
|
for (const theme of ["light", "dark"] as const) {
|
|
for (const width of [1440, 768, 390]) {
|
|
const step = (
|
|
name: string,
|
|
body: (harness: Harness) => Promise<void>,
|
|
) =>
|
|
t.step(
|
|
`${theme}/${width}: ${name}`,
|
|
() => isolated(browser!, body, theme, width),
|
|
);
|
|
await step(
|
|
"expired account replaces history with login then returns to own account",
|
|
async ({ page, go, mutations }) => {
|
|
await page.goto(`${origin}/health`);
|
|
const historyBefore = await page.evaluate(() => history.length);
|
|
await go("/account", "session-expired");
|
|
await at(page, "/login?returnTo=%2Faccount");
|
|
assertEquals(
|
|
await page.evaluate(() => history.length),
|
|
historyBefore + 1,
|
|
);
|
|
const button = page.getByRole("button", {
|
|
name: "Log in with passkey",
|
|
exact: true,
|
|
});
|
|
await button.waitFor();
|
|
assertEquals(await page.locator("input:visible").count(), 0);
|
|
assertEquals(
|
|
await page.getByLabel("Display name", { exact: true }).count(),
|
|
0,
|
|
);
|
|
assertEquals(await page.locator("details[open]").count(), 0);
|
|
assertEquals(
|
|
await page.getByRole("link", {
|
|
name: "Create a new account",
|
|
exact: true,
|
|
}).getAttribute("href"),
|
|
"/register?returnTo=%2Faccount",
|
|
);
|
|
await page.getByRole("link", {
|
|
name: "Activate an account with a code",
|
|
exact: true,
|
|
}).waitFor();
|
|
await button.focus();
|
|
await page.keyboard.press("Tab");
|
|
await page.keyboard.press("Shift+Tab");
|
|
assert(
|
|
await button.evaluate((element) =>
|
|
element === document.activeElement &&
|
|
getComputedStyle(element).outlineStyle !== "none"
|
|
),
|
|
);
|
|
await page.keyboard.press("Enter");
|
|
await at(page, "/account");
|
|
assertEquals(
|
|
mutations.find((entry) =>
|
|
entry.path === "/api/auth/passkeys/login/options"
|
|
)?.keys,
|
|
["browser_origin"],
|
|
);
|
|
await page.getByRole("button", {
|
|
name: "Add passkey to alice",
|
|
exact: true,
|
|
}).waitFor();
|
|
assertEquals(await page.locator("input").count(), 0);
|
|
await page.getByText("Alice", { exact: true }).first().waitFor();
|
|
await noOverflow(page);
|
|
await page.getByRole("button", {
|
|
name: "Add passkey to alice",
|
|
exact: true,
|
|
}).click();
|
|
await additionConfirmed(page).waitFor();
|
|
const additions = mutations.filter((entry) =>
|
|
entry.path.includes("/addition/")
|
|
);
|
|
assertEquals(additions.map((entry) => entry.path), [
|
|
"/api/auth/passkeys/addition/options",
|
|
"/api/auth/passkeys/addition/complete",
|
|
]);
|
|
assertEquals(additions[0].keys, [
|
|
"browser_origin",
|
|
"expected_account_id",
|
|
"expected_authentication_reference",
|
|
"expected_user_id",
|
|
]);
|
|
assertEquals(additions[1].keys, ["challenge_id", "credential"]);
|
|
assertEquals(
|
|
mutations.some((entry) => entry.path.includes("/registration/")),
|
|
false,
|
|
);
|
|
await page.getByRole("button", { name: "Log out", exact: true })
|
|
.click();
|
|
await page.getByRole("heading", { name: "Yoi", exact: true })
|
|
.waitFor();
|
|
assertEquals(new URL(page.url()).pathname, "/");
|
|
await page.reload();
|
|
await page.getByRole("heading", { name: "Yoi", exact: true })
|
|
.waitFor();
|
|
assertEquals(count(mutations, "/api/auth/logout"), 1);
|
|
},
|
|
);
|
|
await step(
|
|
"direct registration has only ID and display name and returns safely",
|
|
async ({ page, go, mutations }) => {
|
|
await go("/register?returnTo=%2Faccount");
|
|
await page.getByLabel("Display name", { exact: true }).waitFor();
|
|
assertEquals(await page.locator("input").count(), 2);
|
|
await register(page);
|
|
await at(page, "/account");
|
|
await page.getByRole("button", {
|
|
name: "Add passkey to alice",
|
|
exact: true,
|
|
}).waitFor();
|
|
assertEquals(
|
|
count(mutations, "/api/auth/passkeys/registration/options"),
|
|
1,
|
|
);
|
|
assertEquals(
|
|
count(mutations, "/api/auth/passkeys/registration/complete"),
|
|
1,
|
|
);
|
|
assertEquals(
|
|
mutations.some((entry) => entry.path.includes("/addition/")),
|
|
false,
|
|
);
|
|
},
|
|
);
|
|
await step(
|
|
"device login restoration never approves without an explicit separate action",
|
|
async ({ page, go, mutations }) => {
|
|
await go("/login/device?user_code=ABCD-EFGH");
|
|
await page.getByLabel("User code", { exact: true }).fill(
|
|
"WXYZ-1234",
|
|
);
|
|
const entry = page.getByRole("link", {
|
|
name: "Log in to continue",
|
|
exact: true,
|
|
});
|
|
const href = await entry.getAttribute("href");
|
|
assert(href);
|
|
const returned = new URL(href, origin).searchParams.get("returnTo");
|
|
assert(returned?.startsWith("/login/device?"));
|
|
assertEquals(
|
|
new URL(returned!, origin).searchParams.get("user_code"),
|
|
"WXYZ-1234",
|
|
);
|
|
assertEquals(
|
|
await page.getByLabel("Account ID (handle)", { exact: true })
|
|
.count(),
|
|
0,
|
|
);
|
|
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
|
|
await entry.click();
|
|
await login(page);
|
|
await page.waitForURL((url) => url.pathname === "/login/device");
|
|
const approve = page.getByRole("button", {
|
|
name: "Approve device login",
|
|
exact: true,
|
|
});
|
|
await approve.waitFor();
|
|
assertEquals(
|
|
await page.getByLabel("User code", { exact: true }).inputValue(),
|
|
"WXYZ-1234",
|
|
);
|
|
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
|
|
await approve.click();
|
|
await page.getByText(
|
|
"Device login approved. You can return to the CLI.",
|
|
{ exact: true },
|
|
).waitFor();
|
|
assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
|
|
},
|
|
);
|
|
await step(
|
|
"disabled registration retains existing-user login",
|
|
async ({ page, go, mutations }) => {
|
|
await go("/register", "policy-disabled");
|
|
await page.getByText(
|
|
"New account registration is disabled. Existing users can still log in.",
|
|
{ exact: true },
|
|
).waitFor();
|
|
assertEquals(
|
|
await page.getByRole("button", {
|
|
name: "Register new account",
|
|
exact: true,
|
|
}).count(),
|
|
0,
|
|
);
|
|
assertEquals(
|
|
await page.getByLabel("Account ID (handle)", { exact: true })
|
|
.count(),
|
|
0,
|
|
);
|
|
const link = page.locator('a[href^="/login"]').first();
|
|
await link.click();
|
|
await login(page);
|
|
await at(page, "/dashboard");
|
|
await page.goto(`${origin}/account`);
|
|
await page.getByRole("button", {
|
|
name: "Add passkey to alice",
|
|
exact: true,
|
|
}).click();
|
|
await additionConfirmed(page).waitFor();
|
|
assertEquals(
|
|
count(mutations, "/api/auth/passkeys/addition/complete"),
|
|
1,
|
|
);
|
|
assertEquals(
|
|
mutations.some((entry) => entry.path.includes("/registration/")),
|
|
false,
|
|
);
|
|
},
|
|
);
|
|
await step(
|
|
"registration stopped during ceremony displays HTTP403 and removes registration after policy refresh",
|
|
async ({ page, go, mutations }) => {
|
|
await go("/register", "policy-stopped");
|
|
await register(page);
|
|
await page.getByRole("alert").filter({ hasText: "403" }).waitFor();
|
|
await page.getByText(
|
|
"New account registration is disabled. Existing users can still log in.",
|
|
{ exact: true },
|
|
).waitFor();
|
|
assertEquals(await page.locator("form").count(), 0);
|
|
assertEquals(
|
|
await page.getByRole("button", {
|
|
name: "Register new account",
|
|
exact: true,
|
|
}).count(),
|
|
0,
|
|
);
|
|
await page.getByRole("link", {
|
|
name: "Log in with an existing account",
|
|
exact: true,
|
|
}).waitFor();
|
|
assertEquals(new URL(page.url()).pathname, "/register");
|
|
assertEquals(
|
|
count(mutations, "/api/auth/passkeys/registration/complete"),
|
|
1,
|
|
);
|
|
await noOverflow(page);
|
|
},
|
|
);
|
|
for (
|
|
const mode of [
|
|
"session-error",
|
|
"session-malformed",
|
|
"session-loading",
|
|
]
|
|
) {
|
|
await step(
|
|
`${mode} does not become known anonymous or allow mutation`,
|
|
async ({ page, go, mutations }) => {
|
|
await go("/login", mode);
|
|
if (mode === "session-loading") {
|
|
await page.getByText("Loading session…", { exact: true })
|
|
.waitFor();
|
|
} else {
|
|
await page.getByRole("alert").waitFor();
|
|
await page.getByRole("button", {
|
|
name: "Retry session",
|
|
exact: true,
|
|
}).waitFor();
|
|
}
|
|
const loginButton = page.getByRole("button", {
|
|
name: "Log in with passkey",
|
|
exact: true,
|
|
});
|
|
if (await loginButton.count()) {
|
|
assertEquals(await loginButton.isEnabled(), false);
|
|
}
|
|
assertEquals(mutations, []);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
for (
|
|
const path of [
|
|
"/account",
|
|
"/register",
|
|
"/login/device?user_code=ABCD-EFGH",
|
|
]
|
|
) {
|
|
await t.step(
|
|
`unknown session on ${path} offers only recovery, never anonymous mutations`,
|
|
() =>
|
|
isolated(browser!, async ({ page, go, mutations }) => {
|
|
await go(path, "session-error");
|
|
if (path === "/account") {
|
|
// 初回protected loadの確認不能はroot loaderの503。画面内再確認とは分ける。
|
|
await page.getByText(/Login status is unknown/i).waitFor();
|
|
} else {
|
|
await page.getByRole("button", {
|
|
name: "Retry session",
|
|
exact: true,
|
|
}).waitFor();
|
|
}
|
|
assertEquals(
|
|
await page.getByRole("link", {
|
|
name: "Log in to continue",
|
|
exact: true,
|
|
}).count(),
|
|
0,
|
|
);
|
|
assertEquals(
|
|
await page.getByLabel("Account ID (handle)", { exact: true })
|
|
.count(),
|
|
0,
|
|
);
|
|
assertEquals(
|
|
await page.getByRole("button", {
|
|
name:
|
|
/^(Add passkey|Register new account|Approve device login)/,
|
|
}).count(),
|
|
0,
|
|
);
|
|
assertEquals(
|
|
new URL(page.url()).pathname,
|
|
new URL(path, origin).pathname,
|
|
);
|
|
assertEquals(mutations, []);
|
|
}),
|
|
);
|
|
}
|
|
await t.step(
|
|
"policy lookup failure is unknown availability, not disabled or permitted registration",
|
|
() =>
|
|
isolated(browser!, async ({ page, go, mutations }) => {
|
|
await go("/register", "policy-error");
|
|
await page.getByRole("alert").waitFor();
|
|
assertEquals(
|
|
await page.getByRole("button", {
|
|
name: "Register new account",
|
|
exact: true,
|
|
}).count(),
|
|
0,
|
|
);
|
|
await page.getByRole("link", {
|
|
name: "Log in with an existing account",
|
|
exact: true,
|
|
}).waitFor();
|
|
assertEquals(mutations, []);
|
|
}),
|
|
);
|
|
await t.step(
|
|
"explicit older-key login sends a handle, returns to device approval without approving",
|
|
() =>
|
|
isolated(browser!, async ({ page, go, mutations }) => {
|
|
await go("/login?returnTo=%2Flogin%2Fdevice%3Fuser_code%3DABCD-EFGH");
|
|
await legacyLogin(page);
|
|
await at(page, "/login/device?user_code=ABCD-EFGH");
|
|
await page.getByRole("button", {
|
|
name: "Approve device login",
|
|
exact: true,
|
|
}).waitFor();
|
|
assertEquals(
|
|
mutations.find((entry) =>
|
|
entry.path === "/api/auth/passkeys/login/options"
|
|
)?.keys,
|
|
["browser_origin", "handle"],
|
|
);
|
|
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
|
|
await page.getByRole("button", {
|
|
name: "Approve device login",
|
|
exact: true,
|
|
}).click();
|
|
await page.getByText(
|
|
"Device login approved. You can return to the CLI.",
|
|
{ exact: true },
|
|
).waitFor();
|
|
}),
|
|
);
|
|
await t.step(
|
|
"explicit compatibility does not require input for a later normal login",
|
|
() =>
|
|
isolated(browser!, async ({ page, go, mutations }) => {
|
|
await go("/login", "login-cancel");
|
|
await legacyLogin(page);
|
|
await page.getByRole("alert").waitFor();
|
|
assertEquals(
|
|
count(mutations, "/api/auth/passkeys/login/complete"),
|
|
0,
|
|
);
|
|
await login(page);
|
|
await page.getByRole("alert").waitFor();
|
|
assertEquals(
|
|
mutations.filter((entry) =>
|
|
entry.path === "/api/auth/passkeys/login/options"
|
|
).map((entry) => entry.keys),
|
|
[
|
|
["browser_origin", "handle"],
|
|
["browser_origin"],
|
|
],
|
|
);
|
|
}),
|
|
);
|
|
await t.step(
|
|
"pending older-key login blocks both forms and ignores late credentials after unmount",
|
|
() =>
|
|
isolated(browser!, async ({ page, go, mutations }) => {
|
|
await go("/login", "login-pending");
|
|
await legacyLogin(page);
|
|
await page.waitForFunction(() =>
|
|
typeof (window as unknown as { __authFixtureRelease?: unknown })
|
|
.__authFixtureRelease === "function"
|
|
);
|
|
for (const name of ["Log in with passkey", "Log in with older key"]) {
|
|
assertEquals(
|
|
await page.getByRole("button", { name, exact: true }).isEnabled(),
|
|
false,
|
|
);
|
|
}
|
|
await page.locator("form").evaluateAll((forms) => {
|
|
for (const form of forms) {
|
|
form.dispatchEvent(
|
|
new Event("submit", { bubbles: true, cancelable: true }),
|
|
);
|
|
}
|
|
});
|
|
assertEquals(count(mutations, "/api/auth/passkeys/login/options"), 1);
|
|
await page.getByRole("link", {
|
|
name: "Create a new account",
|
|
exact: true,
|
|
}).click();
|
|
await page.getByLabel("Display name", { exact: true }).waitFor();
|
|
await page.evaluate(() =>
|
|
(window as unknown as { __authFixtureRelease: () => void })
|
|
.__authFixtureRelease()
|
|
);
|
|
await page.evaluate(() =>
|
|
new Promise<void>((resolve) =>
|
|
requestAnimationFrame(() =>
|
|
requestAnimationFrame(() => resolve())
|
|
)
|
|
)
|
|
);
|
|
assertEquals(new URL(page.url()).pathname, "/register");
|
|
assertEquals(
|
|
count(mutations, "/api/auth/passkeys/login/complete"),
|
|
0,
|
|
);
|
|
}),
|
|
);
|
|
await t.step(
|
|
"leaving a pending login never completes or redirects after navigation",
|
|
() =>
|
|
isolated(browser!, async ({ page, go, mutations }) => {
|
|
await go("/login", "login-pending");
|
|
await login(page);
|
|
await page.waitForFunction(() =>
|
|
typeof (window as unknown as { __authFixtureRelease?: unknown })
|
|
.__authFixtureRelease === "function"
|
|
);
|
|
await page.getByRole("link", {
|
|
name: "Create a new account",
|
|
exact: true,
|
|
}).click();
|
|
await page.getByLabel("Display name", { exact: true }).waitFor();
|
|
await page.evaluate(() =>
|
|
(window as unknown as { __authFixtureRelease: () => void })
|
|
.__authFixtureRelease()
|
|
);
|
|
// 次のpaintまで旧promiseのmicrotaskを進め、固定sleepで完了を推測しない。
|
|
await page.evaluate(() =>
|
|
new Promise<void>((resolve) =>
|
|
requestAnimationFrame(() =>
|
|
requestAnimationFrame(() => resolve())
|
|
)
|
|
)
|
|
);
|
|
assertEquals(new URL(page.url()).pathname, "/register");
|
|
assertEquals(
|
|
count(mutations, "/api/auth/passkeys/login/complete"),
|
|
0,
|
|
);
|
|
}),
|
|
);
|
|
await t.step(
|
|
"retry rechecks unknown session before allowing login",
|
|
() =>
|
|
isolated(browser!, async ({ page, go }) => {
|
|
await go("/login", "session-retry");
|
|
await page.getByRole("button", { name: "Retry session", exact: true })
|
|
.click();
|
|
await login(page);
|
|
await at(page, "/dashboard");
|
|
}),
|
|
);
|
|
for (const route of ["login", "register"]) {
|
|
await t.step(
|
|
`${route} without returnTo uses root fallback`,
|
|
() =>
|
|
isolated(browser!, async ({ page, go }) => {
|
|
await go(`/${route}`);
|
|
await (route === "login" ? login(page) : register(page));
|
|
await at(page, "/dashboard");
|
|
await page.reload();
|
|
assertEquals(new URL(page.url()).pathname, "/dashboard");
|
|
}),
|
|
);
|
|
for (
|
|
const rejected of [
|
|
"https://example.invalid/",
|
|
"//example.invalid/",
|
|
"/\\example.invalid/",
|
|
"account",
|
|
"/login",
|
|
"/register?returnTo=%2Flogin",
|
|
"/activate",
|
|
"/login/../login",
|
|
"/%2f%2fexample.invalid",
|
|
"/account?filter=%252F%252Fexample.invalid",
|
|
"/account#%5Cexample.invalid",
|
|
]
|
|
) {
|
|
await t.step(
|
|
`${route} rejects external or auth-loop returnTo ${rejected}`,
|
|
() =>
|
|
isolated(browser!, async ({ page, go }) => {
|
|
await go(`/${route}?returnTo=${encodeURIComponent(rejected)}`);
|
|
await (route === "login" ? login(page) : register(page));
|
|
await at(page, "/dashboard");
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
for (const purpose of ["login", "registration", "addition"] as const) {
|
|
const route = purpose === "registration"
|
|
? "/register"
|
|
: purpose === "addition"
|
|
? "/account"
|
|
: "/login";
|
|
for (
|
|
const failure of [
|
|
"cancel",
|
|
"unsupported",
|
|
"error",
|
|
"http-error",
|
|
"expired",
|
|
]
|
|
) {
|
|
await t.step(
|
|
`${purpose} ${failure} is visible and does not claim successful authentication`,
|
|
() =>
|
|
isolated(browser!, async ({ page, go, mutations }) => {
|
|
await go(route, `${purpose}-${failure}`);
|
|
if (purpose === "login") await login(page);
|
|
else if (purpose === "registration") await register(page);
|
|
else {await page.getByRole("button", {
|
|
name: "Add passkey to alice",
|
|
exact: true,
|
|
}).click();}
|
|
await page.getByRole("alert").waitFor();
|
|
const path = new URL(page.url()).pathname;
|
|
assert(
|
|
path === route || (purpose === "addition" && path === "/login"),
|
|
);
|
|
if (
|
|
failure === "cancel" || failure === "unsupported" ||
|
|
failure === "error" ||
|
|
failure === "http-error"
|
|
) {
|
|
assertEquals(
|
|
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
|
|
0,
|
|
);
|
|
} else {assertEquals(
|
|
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
|
|
1,
|
|
);}
|
|
}),
|
|
);
|
|
}
|
|
await t.step(
|
|
`${purpose} repeated submit while prompt is pending sends one ceremony`,
|
|
() =>
|
|
isolated(browser!, async ({ page, go, mutations }) => {
|
|
await go(route, `${purpose}-pending`);
|
|
const label = purpose === "login"
|
|
? "Log in with passkey"
|
|
: purpose === "registration"
|
|
? "Register new account"
|
|
: "Add passkey to alice";
|
|
if (purpose === "login") await login(page);
|
|
else if (purpose === "registration") await register(page);
|
|
else {await page.getByRole("button", { name: label, exact: true })
|
|
.click();}
|
|
await page.waitForFunction(() =>
|
|
typeof (window as unknown as { __authFixtureRelease?: unknown })
|
|
.__authFixtureRelease === "function"
|
|
);
|
|
assertEquals(
|
|
count(mutations, `/api/auth/passkeys/${purpose}/options`),
|
|
1,
|
|
);
|
|
assertEquals(
|
|
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
|
|
0,
|
|
);
|
|
await page.getByRole("button", {
|
|
name: label,
|
|
exact: true,
|
|
includeHidden: true,
|
|
})
|
|
.evaluate((button) => {
|
|
button.dispatchEvent(
|
|
new MouseEvent("click", { bubbles: true }),
|
|
);
|
|
button.dispatchEvent(
|
|
new MouseEvent("click", { bubbles: true }),
|
|
);
|
|
});
|
|
await page.locator("form").evaluateAll((forms) => {
|
|
for (const form of forms) {
|
|
form.dispatchEvent(
|
|
new Event("submit", { bubbles: true, cancelable: true }),
|
|
);
|
|
form.dispatchEvent(
|
|
new Event("submit", { bubbles: true, cancelable: true }),
|
|
);
|
|
}
|
|
});
|
|
await page.evaluate(() =>
|
|
(window as unknown as { __authFixtureRelease: () => void })
|
|
.__authFixtureRelease()
|
|
);
|
|
if (purpose === "addition") {
|
|
await additionConfirmed(page).waitFor();
|
|
} else await at(page, "/dashboard");
|
|
assertEquals(
|
|
count(mutations, `/api/auth/passkeys/${purpose}/options`),
|
|
1,
|
|
);
|
|
assertEquals(
|
|
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
|
|
1,
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
await t.step(
|
|
"expired device request stays unapproved",
|
|
() =>
|
|
isolated(browser!, async ({ page, go, mutations }) => {
|
|
await go("/login/device?user_code=ABCD-EFGH", "device-expired");
|
|
await page.getByRole("link", {
|
|
name: "Log in to continue",
|
|
exact: true,
|
|
}).click();
|
|
await login(page);
|
|
await page.getByRole("button", {
|
|
name: "Approve device login",
|
|
exact: true,
|
|
}).click();
|
|
await page.getByRole("alert").filter({
|
|
hasText:
|
|
/^Authentication request was rejected \(400\)\. Check your session or restart the operation\.$/,
|
|
}).waitFor();
|
|
assertEquals(new URL(page.url()).pathname, "/login/device");
|
|
assertEquals(
|
|
await page.getByText(
|
|
"Device login approved. You can return to the CLI.",
|
|
{ exact: true },
|
|
).count(),
|
|
0,
|
|
);
|
|
assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
|
|
}),
|
|
);
|
|
await t.step(
|
|
"activation entry remains limited until credential completion",
|
|
() =>
|
|
isolated(browser!, async ({ page, go, mutations }) => {
|
|
await go("/login", "activation-pending");
|
|
await page.getByRole("link", {
|
|
name: "Activate an account with a code",
|
|
exact: true,
|
|
}).click();
|
|
// 同名labelのlogin入力が残る間にfillしない。遷移先の固有controlで同期する。
|
|
await page.waitForURL((url) => url.pathname === "/activate");
|
|
await page.locator("#activation-handle").fill("alice");
|
|
await page.locator("#activation-code").fill(
|
|
"synthetic-input-only",
|
|
);
|
|
await page.getByRole("button", {
|
|
name: "Register Passkey and activate",
|
|
exact: true,
|
|
}).click();
|
|
await page.getByText(
|
|
"Passkey registration pending. Account activation has not been confirmed.",
|
|
{ exact: true },
|
|
).waitFor();
|
|
assertEquals(count(mutations, "/api/auth/activation/complete"), 0);
|
|
assertEquals(
|
|
mutations.some((entry) =>
|
|
/device-login|addition|registration/.test(entry.path)
|
|
),
|
|
false,
|
|
);
|
|
assertEquals(await page.locator(".app-shell").count(), 0);
|
|
await page.getByRole("button", {
|
|
name: "Cancel registration",
|
|
exact: true,
|
|
}).click();
|
|
await page.getByRole("alert").waitFor();
|
|
const link = page.locator('a[href="/login"]');
|
|
await link.waitFor();
|
|
await link.click();
|
|
await page.waitForURL((url) => url.pathname === "/login");
|
|
await page.getByRole("button", {
|
|
name: "Log in with passkey",
|
|
exact: true,
|
|
}).waitFor();
|
|
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
|
|
}),
|
|
);
|
|
} finally {
|
|
await browser?.close();
|
|
await stopOwnedProcesses(processes);
|
|
}
|
|
});
|