feat: separate login registration and own account flows
This commit is contained in:
@@ -172,7 +172,7 @@ Deno.test("activation standalone shell supports issuance, reissue, denial, resta
|
||||
await checkLink(
|
||||
page.getByRole("link", {
|
||||
name:
|
||||
"Already registered or completion uncertain? Check Passkey login from Account",
|
||||
"Already registered or completion uncertain? Log in with a Passkey",
|
||||
}),
|
||||
);
|
||||
await fillActivation();
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
/// <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 {
|
||||
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) => Promise<void>;
|
||||
};
|
||||
const login = async (page: Page) => {
|
||||
await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice");
|
||||
await page.getByRole("button", { name: "Log in with passkey", 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 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[] = [];
|
||||
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") => {
|
||||
const url = new URL(path, origin);
|
||||
url.searchParams.set("fixture", mode);
|
||||
await page.goto(url.href);
|
||||
},
|
||||
});
|
||||
await noOverflow(page);
|
||||
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/web-ux/auth-interactions/processes`,
|
||||
[],
|
||||
);
|
||||
let browser: Browser | undefined;
|
||||
try {
|
||||
browser = await chromium.launch();
|
||||
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(
|
||||
"anonymous 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");
|
||||
await at(page, "/login?returnTo=%2Faccount");
|
||||
assertEquals(
|
||||
await page.evaluate(() => history.length),
|
||||
historyBefore + 1,
|
||||
);
|
||||
const id = page.getByLabel("Account ID (handle)", { exact: true });
|
||||
await id.waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Display name", { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(await page.locator("input").count(), 1);
|
||||
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 id.fill("alice");
|
||||
await id.press("Tab");
|
||||
const button = page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
});
|
||||
assert(
|
||||
await button.evaluate((element) =>
|
||||
element === document.activeElement &&
|
||||
getComputedStyle(element).outlineStyle !== "none"
|
||||
),
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
await at(page, "/account");
|
||||
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 page.getByRole("status").filter({
|
||||
hasText: /added|registered/i,
|
||||
}).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"]);
|
||||
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("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/login");
|
||||
await page.reload();
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
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, "/");
|
||||
await page.goto(`${origin}/account`);
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("status").filter({ hasText: /added/i })
|
||||
.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");
|
||||
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(
|
||||
"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, "/");
|
||||
}),
|
||||
);
|
||||
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, "/");
|
||||
await page.reload();
|
||||
assertEquals(new URL(page.url()).pathname, "/");
|
||||
}),
|
||||
);
|
||||
for (
|
||||
const rejected of [
|
||||
"https://example.invalid/",
|
||||
"//example.invalid/",
|
||||
"/\\example.invalid/",
|
||||
"account",
|
||||
"/login",
|
||||
"/register?returnTo=%2Flogin",
|
||||
"/activate",
|
||||
"/login/../login",
|
||||
]
|
||||
) {
|
||||
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, "/");
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const purpose of ["login", "registration", "addition"] as const) {
|
||||
const route = purpose === "registration"
|
||||
? "/register"
|
||||
: purpose === "addition"
|
||||
? "/account"
|
||||
: "/login";
|
||||
for (const failure of ["cancel", "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 === "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 })
|
||||
.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 page.getByRole("status").filter({
|
||||
hasText: /added|registered/i,
|
||||
}).waitFor();
|
||||
} else await at(page, "/");
|
||||
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: /invalid|expired/i })
|
||||
.waitFor();
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
// shared captureがscreenshot/snapshot/review-contextを採取する。合否と本人目視は呼出元が行う。
|
||||
// 本番buildに合成HTTP/WebAuthnだけを接続し、撮影専用CSSや認証情報は使わない。
|
||||
import { resolve } from "@std/path";
|
||||
import { capture } from "../../../../tools/web-ux/src/capture.ts";
|
||||
import type {
|
||||
Interaction,
|
||||
RouteScenario,
|
||||
Scenario,
|
||||
} from "../../../../tools/web-ux/src/types.ts";
|
||||
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const phase = Deno.args[0] ?? "after";
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(phase)) {
|
||||
throw new Error("Expected safe capture label");
|
||||
}
|
||||
const ready = (selector: string) => ({ kind: "selector" as const, selector });
|
||||
const click = (selector: string): Interaction => ({
|
||||
action: "click",
|
||||
selector,
|
||||
});
|
||||
const fill = (selector: string, value: string): Interaction => ({
|
||||
action: "fill",
|
||||
selector,
|
||||
value,
|
||||
});
|
||||
const id = 'label:has-text("ID (handle)") input';
|
||||
const login = [
|
||||
fill(id, "alice"),
|
||||
click('button:text-is("Log in with passkey")'),
|
||||
];
|
||||
const register = [
|
||||
fill(id, "alice"),
|
||||
fill('label:has-text("Display name") input', "Alice"),
|
||||
click('button:text-is("Register new account")'),
|
||||
];
|
||||
const route = (
|
||||
id: string,
|
||||
path: string,
|
||||
selector: string,
|
||||
capturePoints: RouteScenario["capturePoints"] = [{
|
||||
id: "initial",
|
||||
label: id,
|
||||
fullPage: true,
|
||||
}],
|
||||
): RouteScenario => ({
|
||||
id,
|
||||
label: id,
|
||||
path,
|
||||
goal:
|
||||
"Review standalone production auth UI, navigation, focus, state and copy; not Server authorization.",
|
||||
dataState: `Synthetic ${id}; no real session, credential or activation code.`,
|
||||
ready: ready(selector),
|
||||
capturePoints,
|
||||
});
|
||||
const routes: RouteScenario[] = [
|
||||
route("login", "/login?fixture=anonymous", `${id}:enabled`, [
|
||||
{
|
||||
id: "initial",
|
||||
label: "ID-only login; registration and activation entries",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "keyboard-focus",
|
||||
label: "Login keyboard focus",
|
||||
fullPage: true,
|
||||
interaction: [fill(id, "alice"), {
|
||||
action: "press",
|
||||
selector: id,
|
||||
key: "Tab",
|
||||
}],
|
||||
ready: ready('button:text-is("Log in with passkey"):focus-visible'),
|
||||
},
|
||||
]),
|
||||
route(
|
||||
"account-redirect",
|
||||
"/account?fixture=anonymous",
|
||||
'button:text-is("Log in with passkey")',
|
||||
),
|
||||
route("register", "/register?fixture=anonymous", `${id}:enabled`, [
|
||||
{
|
||||
id: "initial",
|
||||
label: "ID and distinct display name; new account only",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "keyboard-focus",
|
||||
label: "Register keyboard focus",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
fill(id, "alice"),
|
||||
fill('label:has-text("Display name") input', "Alice"),
|
||||
{
|
||||
action: "press",
|
||||
selector: 'label:has-text("Display name") input',
|
||||
key: "Tab",
|
||||
},
|
||||
],
|
||||
ready: ready('button:text-is("Register new account"):focus-visible'),
|
||||
},
|
||||
]),
|
||||
route(
|
||||
"account",
|
||||
"/account?fixture=account",
|
||||
'button:text-is("Add passkey to alice")',
|
||||
[
|
||||
{
|
||||
id: "initial",
|
||||
label: "Own account only; no target handle field",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "addition-confirmed",
|
||||
label: "Own Passkey addition confirmed",
|
||||
fullPage: true,
|
||||
interaction: [click('button:text-is("Add passkey to alice")')],
|
||||
ready: ready('[role="status"]:has-text("Passkey added")'),
|
||||
},
|
||||
{
|
||||
id: "logout",
|
||||
label: "Log out returns to login",
|
||||
fullPage: true,
|
||||
interaction: [click('button:text-is("Log out")')],
|
||||
ready: ready('button:text-is("Log in with passkey")'),
|
||||
},
|
||||
],
|
||||
),
|
||||
route(
|
||||
"device-anonymous",
|
||||
"/login/device?user_code=ABCD-EFGH&fixture=anonymous",
|
||||
'a:text-is("Log in to continue")',
|
||||
[
|
||||
{
|
||||
id: "initial",
|
||||
label: "User code and login continuation; no implicit approval",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "login",
|
||||
label: "Dedicated login with device returnTo",
|
||||
fullPage: true,
|
||||
interaction: [click('a:text-is("Log in to continue")')],
|
||||
ready: ready(`${id}:enabled`),
|
||||
},
|
||||
{
|
||||
id: "authenticated-unapproved",
|
||||
label: "Login complete; explicit approval still required",
|
||||
fullPage: true,
|
||||
interaction: login,
|
||||
ready: ready('button:text-is("Approve device login"):enabled'),
|
||||
},
|
||||
{
|
||||
id: "approved",
|
||||
label: "Separate explicit approval confirmed",
|
||||
fullPage: true,
|
||||
interaction: [click('button:text-is("Approve device login")')],
|
||||
ready: ready(
|
||||
'text="Device login approved. You can return to the CLI."',
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
route(
|
||||
"session-loading",
|
||||
"/login?fixture=session-loading",
|
||||
'text="Loading session…"',
|
||||
),
|
||||
route(
|
||||
"session-error",
|
||||
"/login?fixture=session-error",
|
||||
'button:text-is("Retry session")',
|
||||
),
|
||||
route(
|
||||
"account-session-error",
|
||||
"/account?fixture=session-error",
|
||||
'button:text-is("Retry session")',
|
||||
),
|
||||
route(
|
||||
"register-session-error",
|
||||
"/register?fixture=session-error",
|
||||
'button:text-is("Retry session")',
|
||||
),
|
||||
route(
|
||||
"device-session-error",
|
||||
"/login/device?user_code=ABCD-EFGH&fixture=session-error",
|
||||
'button:text-is("Retry session")',
|
||||
),
|
||||
route(
|
||||
"policy-disabled",
|
||||
"/register?fixture=policy-disabled",
|
||||
'text="New account registration is disabled. Existing users can still log in."',
|
||||
),
|
||||
route("policy-error", "/register?fixture=policy-error", '[role="alert"]'),
|
||||
route("policy-stopped", "/register?fixture=policy-stopped", `${id}:enabled`, [
|
||||
{
|
||||
id: "completion-403",
|
||||
label:
|
||||
"Policy refreshed to disabled after intentional registration HTTP403",
|
||||
fullPage: true,
|
||||
interaction: register,
|
||||
ready: ready(
|
||||
'text="New account registration is disabled. Existing users can still log in."',
|
||||
),
|
||||
},
|
||||
]),
|
||||
...["login", "registration", "addition"].flatMap((purpose) =>
|
||||
["pending", "cancel", "error", "expired"].map((state) => {
|
||||
const path = purpose === "registration"
|
||||
? "/register"
|
||||
: purpose === "addition"
|
||||
? "/account"
|
||||
: "/login";
|
||||
const interaction = purpose === "login"
|
||||
? login
|
||||
: purpose === "registration"
|
||||
? register
|
||||
: [click('button:text-is("Add passkey to alice")')];
|
||||
return route(
|
||||
`${purpose}-${state}`,
|
||||
`${path}?fixture=${purpose}-${state}`,
|
||||
purpose === "addition"
|
||||
? 'button:text-is("Add passkey to alice")'
|
||||
: `${id}:enabled`,
|
||||
[{
|
||||
id: "result",
|
||||
label: `${purpose} ${state}; not a successful ceremony`,
|
||||
fullPage: true,
|
||||
interaction,
|
||||
ready: ready(
|
||||
state === "pending" ? '[role="status"]' : '[role="alert"]',
|
||||
),
|
||||
}],
|
||||
);
|
||||
})
|
||||
),
|
||||
route(
|
||||
"activation-entry",
|
||||
"/login?fixture=activation-pending",
|
||||
'a:text-is("Activate an account with a code")',
|
||||
[
|
||||
{
|
||||
id: "entry",
|
||||
label: "Existing activation route from login",
|
||||
fullPage: true,
|
||||
interaction: [click('a:text-is("Activate an account with a code")')],
|
||||
ready: ready("#activation-code:enabled"),
|
||||
},
|
||||
{
|
||||
id: "limited",
|
||||
label:
|
||||
"No ordinary session or device approval before Passkey completion",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
fill("#activation-handle", "alice"),
|
||||
fill("#activation-code", "synthetic-input-only"),
|
||||
click('button:text-is("Register Passkey and activate")'),
|
||||
],
|
||||
ready: ready('button:text-is("Cancel registration")'),
|
||||
},
|
||||
{
|
||||
id: "cancelled",
|
||||
label: "Activation cancellation and login recovery entry",
|
||||
fullPage: true,
|
||||
interaction: [click('button:text-is("Cancel registration")')],
|
||||
ready: ready('[role="alert"]'),
|
||||
},
|
||||
],
|
||||
),
|
||||
];
|
||||
await Deno.mkdir(`${root}/target/web-ux`, { recursive: true });
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const scenario: Scenario = {
|
||||
schemaVersion: 1,
|
||||
id: `auth-${colorScheme}-${width}`,
|
||||
title:
|
||||
"Standalone authentication synthetic projection on production build",
|
||||
baseUrl: "http://127.0.0.1:15392",
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
personas: [{
|
||||
id: "fixture",
|
||||
label: "Synthetic HTTP/WebAuthn only",
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [{ width, height: 900, label: `${width}` }],
|
||||
routes,
|
||||
redact: {
|
||||
// 初期撮影後にもfillする。空の入力まで恒久的に隠すと次の操作が不能になる。
|
||||
// 合成codeはsubmit直後に破棄され、pendingのdisabled入力だけを隠す。
|
||||
selectors: ["#activation-code:disabled", ".secret"],
|
||||
text: ["synthetic-input-only"],
|
||||
},
|
||||
processes: [{
|
||||
id: "auth-projection",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/auth-browser/fixture-server.ts",
|
||||
"15392",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15392/health",
|
||||
}],
|
||||
};
|
||||
const scenarioPath =
|
||||
`${root}/target/web-ux/auth-${phase}-${colorScheme}-${width}.json`;
|
||||
await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2));
|
||||
const result = await capture({
|
||||
scenarioPath,
|
||||
outputDirectory: `${root}/target/web-ux`,
|
||||
runId: `auth-${phase}-${colorScheme}-${width}`,
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
colorScheme,
|
||||
width,
|
||||
status: result.status,
|
||||
captures: result.captures.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// 本番build/client/shellだけを検証する合成HTTP/WebAuthn境界。
|
||||
// 実認証・署名・cookie安全性・policyの永続化は検証しない。本文/credentialは記録しない。
|
||||
import { extname, join, resolve } from "@std/path";
|
||||
import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts";
|
||||
|
||||
const [port, build] = Deno.args;
|
||||
if (!port || !build) throw new Error("Expected port and production build root");
|
||||
const root = resolve(build);
|
||||
const user = {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
};
|
||||
type State = {
|
||||
mode: string;
|
||||
signedIn: boolean;
|
||||
whoamiCalls: number;
|
||||
selfRegistrationEnabled: boolean;
|
||||
};
|
||||
const sessions = new Map<string, State>();
|
||||
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",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
const options = (login: boolean) => ({
|
||||
challenge_id: "synthetic-challenge-only",
|
||||
public_key: login ? { challenge: "dWk" } : {
|
||||
challenge: "dWk",
|
||||
rp: { name: "UI projection" },
|
||||
user: { id: "dWk", name: "alice", displayName: "Alice" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
});
|
||||
|
||||
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
if (path === "/health") return new Response("ok");
|
||||
const selected = url.searchParams.get("fixture");
|
||||
let id = request.headers.get("cookie")?.match(/auth-fixture=([\w-]+)/)?.[1];
|
||||
// routeごとに独立。クライアントgoto後も同じ合成sessionをwhoamiへ投影する。
|
||||
if (selected || !id || !sessions.has(id)) {
|
||||
id = crypto.randomUUID();
|
||||
sessions.set(id, {
|
||||
mode: selected ?? "anonymous",
|
||||
signedIn: selected === "account" ||
|
||||
selected?.startsWith("addition-") === true,
|
||||
whoamiCalls: 0,
|
||||
selfRegistrationEnabled: selected !== "policy-disabled",
|
||||
});
|
||||
}
|
||||
const state = sessions.get(id)!;
|
||||
const { mode } = state;
|
||||
if (path === "/__ui_fixture/webauthn.js") {
|
||||
let extra = "";
|
||||
if (mode.endsWith("-cancel")) {
|
||||
extra =
|
||||
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Passkey cancelled','NotAllowedError')}});`;
|
||||
} else if (mode.endsWith("-error")) {
|
||||
extra =
|
||||
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Synthetic authenticator failure','UnknownError')}});`;
|
||||
} else if (mode.endsWith("-pending")) {
|
||||
extra =
|
||||
`for (const method of ['get','create']) { const original=navigator.credentials[method].bind(navigator.credentials); Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{ await new Promise(resolve=>{window.__authFixtureRelease=resolve}); return original(); }}); }`;
|
||||
}
|
||||
return new Response(setupWebAuthnProjection + extra, {
|
||||
headers: {
|
||||
"content-type": "text/javascript",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
state.whoamiCalls++;
|
||||
if (mode === "session-loading") {
|
||||
return await new Promise<Response>(() => {});
|
||||
}
|
||||
if (
|
||||
mode === "session-error" ||
|
||||
(mode === "session-retry" && state.whoamiCalls === 1)
|
||||
) {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
if (mode === "session-malformed") {
|
||||
return Response.json({ actor: { handle: "untrusted" } });
|
||||
}
|
||||
return Response.json({
|
||||
actor: state.signedIn
|
||||
? { ...user, auth_method: "browser_session" }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/registration-policy") {
|
||||
if (mode === "policy-error") return new Response(null, { status: 503 });
|
||||
return Response.json({
|
||||
self_registration_enabled: state.selfRegistrationEnabled,
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") return Response.json({ is_admin: false });
|
||||
if (path === "/api/workspaces") {
|
||||
// fallbackは現行Workspace選択まで。公開トップ/T760の挙動を新設しない。
|
||||
return state.signedIn
|
||||
? Response.json([])
|
||||
: new Response(null, { status: 401 });
|
||||
}
|
||||
if (path === "/api/auth/logout") {
|
||||
state.signedIn = false;
|
||||
return Response.json({ status: "logged_out" });
|
||||
}
|
||||
if (path === "/api/auth/device-login/approve") {
|
||||
if (!state.signedIn) return new Response(null, { status: 401 });
|
||||
if (mode === "device-expired") return new Response(null, { status: 410 });
|
||||
return Response.json({ status: "approved", user });
|
||||
}
|
||||
const passkey = path.match(
|
||||
/^\/api\/auth\/passkeys\/(login|registration|addition)\/(options|complete)$/,
|
||||
);
|
||||
if (passkey) {
|
||||
const [, purpose, stage] = passkey;
|
||||
if (purpose === "addition" && !state.signedIn) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
if (purpose === "registration") {
|
||||
if (mode === "policy-stopped" && stage === "complete") {
|
||||
state.selfRegistrationEnabled = false;
|
||||
}
|
||||
if (!state.selfRegistrationEnabled) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
}
|
||||
if (mode.endsWith("-http-error") && stage === "options") {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
if (mode === `${purpose}-expired` && stage === "complete") {
|
||||
if (purpose === "addition") state.signedIn = false;
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
if (stage === "options") return Response.json(options(purpose === "login"));
|
||||
state.signedIn = true;
|
||||
return Response.json({ user });
|
||||
}
|
||||
if (path === "/api/auth/activation/options") {
|
||||
return Response.json(options(false));
|
||||
}
|
||||
if (path === "/api/auth/activation/complete") {
|
||||
state.signedIn = true;
|
||||
return Response.json({ user });
|
||||
}
|
||||
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
|
||||
let file: string;
|
||||
try {
|
||||
file = join(root, decodeURIComponent(path));
|
||||
} catch {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
if (!file.startsWith(root + "/")) return new Response(null, { status: 400 });
|
||||
let bytes: Uint8Array;
|
||||
let extension = extname(file);
|
||||
try {
|
||||
bytes = await Deno.readFile(file);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Deno.errors.NotFound) &&
|
||||
!(error instanceof Deno.errors.IsADirectory)
|
||||
) throw error;
|
||||
bytes = await Deno.readFile(join(root, "index.html"));
|
||||
extension = ".html";
|
||||
}
|
||||
if (extension === ".html") {
|
||||
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 (extension === ".html") {
|
||||
headers.set(
|
||||
"set-cookie",
|
||||
`auth-fixture=${id}; Path=/; SameSite=Strict; HttpOnly`,
|
||||
);
|
||||
}
|
||||
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
|
||||
});
|
||||
@@ -4,7 +4,10 @@
|
||||
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";
|
||||
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) {
|
||||
@@ -63,7 +66,9 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) {
|
||||
httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`);
|
||||
httpErrors.push(
|
||||
`${response.status()} ${new URL(response.url()).pathname}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
page.on(
|
||||
@@ -80,18 +85,34 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
|
||||
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();
|
||||
await page.goto("http://127.0.0.1:15379/?fixture=admin-on");
|
||||
await page.getByRole("heading", { name: "Workspaces", exact: true })
|
||||
.waitFor();
|
||||
if (
|
||||
await page.getByRole("button", { name: "Show sidebar", exact: true }).isVisible()
|
||||
) await page.getByRole("button", { name: "Show sidebar", exact: true }).click();
|
||||
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: "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");
|
||||
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").innerText(),
|
||||
"Hosting settings",
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator(":focus").evaluate((element) =>
|
||||
getComputedStyle(element).outlineStyle !== "none"
|
||||
@@ -99,42 +120,80 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
const edit = page.getByRole("button", { name: "Edit registration policy" });
|
||||
const edit = page.getByRole("button", {
|
||||
name: "Edit registration policy",
|
||||
});
|
||||
await edit.waitFor();
|
||||
// Globalの入口からhostingへ進んだ後はWorkspace shellを離れる。
|
||||
// Workspace未作成でも管理できることと整合させる。
|
||||
assertEquals(await nav.count(), 0);
|
||||
await page.getByRole("heading", { name: "Hosting settings", exact: true }).waitFor();
|
||||
await page.getByRole("heading", {
|
||||
name: "Hosting settings",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
if (
|
||||
await page.getByRole("button", { name: "Hide sidebar", exact: true }).isVisible() &&
|
||||
await page.getByRole("button", {
|
||||
name: "Hide sidebar",
|
||||
exact: true,
|
||||
}).isVisible() &&
|
||||
width < 768
|
||||
) await page.getByRole("button", { name: "Hide sidebar", exact: true }).click();
|
||||
) {
|
||||
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" });
|
||||
const checkbox = page.getByRole("checkbox", {
|
||||
name: "Allow new account registration",
|
||||
});
|
||||
assertEquals(
|
||||
await checkbox.evaluate((element) => element === document.activeElement),
|
||||
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.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");
|
||||
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.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);
|
||||
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.goto(
|
||||
`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`,
|
||||
);
|
||||
await page.getByText(
|
||||
mode === "ordinary"
|
||||
? "Hosting administrator access is required."
|
||||
@@ -144,41 +203,62 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
|
||||
assertEquals(await edit.count(), 0);
|
||||
assertEquals(await checkbox.count(), 0);
|
||||
assertEquals(
|
||||
await nav.getByRole("link", { name: "Hosting settings", exact: true }).count(),
|
||||
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.goto(
|
||||
"http://127.0.0.1:15379/register?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 page.getByRole("button", { name: "Register new account" })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await page.getByRole("link", {
|
||||
name: "Log in with an existing account",
|
||||
}).waitFor();
|
||||
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();
|
||||
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(),
|
||||
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 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("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(),
|
||||
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 page.getByRole("button", {
|
||||
name: "Reload settings",
|
||||
exact: true,
|
||||
}).click();
|
||||
await edit.waitFor();
|
||||
assertEquals(await page.getByRole("alert").count(), 0);
|
||||
assertEquals(await edit.isEnabled(), true);
|
||||
@@ -189,7 +269,11 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
|
||||
"/hosting/settings",
|
||||
'button:has-text("Retry loading settings")',
|
||||
],
|
||||
["account-error", "/account", 'button:has-text("Retry registration availability")'],
|
||||
[
|
||||
"account-error",
|
||||
"/register",
|
||||
'button:has-text("Retry registration availability")',
|
||||
],
|
||||
[
|
||||
"admin-loading",
|
||||
"/hosting/settings",
|
||||
@@ -197,7 +281,7 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
|
||||
],
|
||||
[
|
||||
"account-loading",
|
||||
"/account",
|
||||
"/register",
|
||||
'[role="status"]:has-text("Checking registration availability")',
|
||||
],
|
||||
]
|
||||
@@ -242,7 +326,10 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
|
||||
}
|
||||
}
|
||||
}
|
||||
await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2));
|
||||
await Deno.writeTextFile(
|
||||
`${evidence}/results.json`,
|
||||
JSON.stringify(results, null, 2),
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { assertEquals } from "jsr:@std/assert";
|
||||
import { load, ssr } from "../src/routes/+layout.ts";
|
||||
|
||||
Deno.test("activation and hosting shells never require Workspace catalog authority", async () => {
|
||||
Deno.test("authentication and hosting shells never require Workspace catalog authority", async () => {
|
||||
for (
|
||||
const path of [
|
||||
"/activate",
|
||||
"/login",
|
||||
"/register",
|
||||
"/account",
|
||||
"/login/device",
|
||||
"/hosting/accounts",
|
||||
"/hosting/settings",
|
||||
"/hosting/setup",
|
||||
|
||||
Reference in New Issue
Block a user