feat: activate provisional users with scoped passkey enrollment
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
/// <reference lib="dom" />
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { chromium, type Locator } from "playwright";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
} from "../../../../tools/web-ux/src/processes.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
// 本番shellとclientを接続する合成HTTP/WebAuthn試験。Server認証/atomic保存のE2Eではない。
|
||||
Deno.test("activation standalone shell supports issuance, reissue, denial, restart and confirmed-only activation", async () => {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "activation-browser",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/activation-browser/fixture-server.ts",
|
||||
"15381",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15381/health",
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/hosting-settings-light.json`,
|
||||
`${root}/target/web-ux/activation-interactions/processes`,
|
||||
[],
|
||||
);
|
||||
const browser = await chromium.launch();
|
||||
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 requested: string[] = [];
|
||||
const errors: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname.startsWith("/api/")) {
|
||||
requested.push(
|
||||
`${request.method()} ${new URL(request.url()).pathname}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
page.on("pageerror", () => errors.push("pageerror"));
|
||||
const go = async (path: string, mode: string) => {
|
||||
await page.goto(`http://127.0.0.1:15381${path}?fixture=${mode}`);
|
||||
};
|
||||
const noOverflow = async () =>
|
||||
assertEquals(
|
||||
await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth > innerWidth
|
||||
),
|
||||
false,
|
||||
);
|
||||
const linkStyle = (link: Locator) =>
|
||||
link.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const probe = document.createElement("span");
|
||||
probe.hidden = true;
|
||||
element.append(probe);
|
||||
const token = (name: string) => {
|
||||
probe.style.color = `var(${name})`;
|
||||
return getComputedStyle(probe).color;
|
||||
};
|
||||
const result = {
|
||||
color: style.color,
|
||||
outline: style.outlineColor,
|
||||
width: style.outlineWidth,
|
||||
kind: style.outlineStyle,
|
||||
underline: style.textDecorationLine,
|
||||
strong: token("--text-strong"),
|
||||
text: token("--text"),
|
||||
accent: token("--accent"),
|
||||
};
|
||||
probe.remove();
|
||||
return result;
|
||||
});
|
||||
const checkLink = async (link: Locator) => {
|
||||
await page.mouse.move(0, 0);
|
||||
const normal = await linkStyle(link);
|
||||
assertEquals(normal.color, normal.strong);
|
||||
assert(normal.underline.includes("underline"));
|
||||
await link.hover();
|
||||
const hovered = await linkStyle(link);
|
||||
assertEquals(hovered.color, hovered.text);
|
||||
await page.mouse.move(0, 0);
|
||||
await page.keyboard.press("Tab");
|
||||
await link.focus();
|
||||
const focused = await linkStyle(link);
|
||||
assertEquals(focused.kind, "solid");
|
||||
assertEquals(focused.width, "2px");
|
||||
assertEquals(focused.outline, focused.accent);
|
||||
};
|
||||
await go("/hosting/settings", "admin");
|
||||
await page.getByRole("link", {
|
||||
name: "Create accounts or reissue activation codes",
|
||||
}).click();
|
||||
await page.getByLabel("Account ID (handle)").waitFor();
|
||||
await checkLink(
|
||||
page.getByRole("link", { name: "Hosting settings", exact: true }),
|
||||
);
|
||||
const handle = page.getByLabel("Account ID (handle)");
|
||||
await handle.fill(" ALICE ");
|
||||
const issue = page.getByRole("button", {
|
||||
name: "Create account and issue code",
|
||||
});
|
||||
await handle.press("Tab");
|
||||
assert(
|
||||
await issue.evaluate((el) =>
|
||||
el === document.activeElement &&
|
||||
getComputedStyle(el).outlineStyle !== "none"
|
||||
),
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.getByRole("heading", { name: "Activation code issued" })
|
||||
.waitFor();
|
||||
await checkLink(
|
||||
page.getByRole("link", { name: "/activate", exact: true }),
|
||||
);
|
||||
assertEquals(await page.locator(".secret").count(), 1);
|
||||
await noOverflow();
|
||||
await page.getByRole("button", { name: "Hide code" }).click();
|
||||
assertEquals(await page.locator(".secret").count(), 0);
|
||||
await page.getByLabel("Operation").selectOption("reissue");
|
||||
await page.getByRole("button", { name: "Reissue activation code" })
|
||||
.click();
|
||||
await page.getByRole("heading", { name: "Activation code issued" })
|
||||
.waitFor();
|
||||
assert(
|
||||
requested.includes("POST /api/hosting/accounts/activation-code"),
|
||||
);
|
||||
for (const mode of ["anonymous", "ordinary"]) {
|
||||
const before = requested.filter((path) =>
|
||||
path.startsWith("POST ")
|
||||
).length;
|
||||
await go("/hosting/accounts", mode);
|
||||
await page.getByText(
|
||||
mode === "anonymous"
|
||||
? "Sign in with a hosting administrator account to issue activation codes."
|
||||
: "Hosting administrator access is required.",
|
||||
).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Account ID (handle)").count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
requested.filter((path) => path.startsWith("POST ")).length,
|
||||
before,
|
||||
);
|
||||
await noOverflow();
|
||||
}
|
||||
const fillActivation = async () => {
|
||||
await page.getByLabel("Account ID (handle)").fill("alice");
|
||||
await page.getByLabel("Activation code", { exact: true }).fill(
|
||||
"synthetic-input-only",
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Register Passkey and activate",
|
||||
}).click();
|
||||
};
|
||||
await go("/activate", "activation-pending");
|
||||
await page.getByLabel("Activation code", { exact: true }).waitFor();
|
||||
await checkLink(
|
||||
page.getByRole("link", {
|
||||
name:
|
||||
"Already registered or completion uncertain? Check Passkey login from Account",
|
||||
}),
|
||||
);
|
||||
await fillActivation();
|
||||
await page.getByText(
|
||||
"Passkey registration pending. Account activation has not been confirmed.",
|
||||
).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Activation code", { exact: true })
|
||||
.inputValue(),
|
||||
"",
|
||||
);
|
||||
assertEquals(
|
||||
requested.filter((path) =>
|
||||
path === "POST /api/auth/activation/complete"
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
await page.getByRole("button", { name: "Cancel registration" })
|
||||
.click();
|
||||
await page.getByRole("alert").waitFor();
|
||||
assertEquals(await page.getByText(/You are signed in/).count(), 0);
|
||||
await noOverflow();
|
||||
await page.reload();
|
||||
await page.getByRole("button", {
|
||||
name: "Register Passkey and activate",
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Activation code", { exact: true })
|
||||
.inputValue(),
|
||||
"",
|
||||
);
|
||||
for (
|
||||
const mode of [
|
||||
"activation-cancel",
|
||||
"activation-unknown",
|
||||
"activation-reject",
|
||||
]
|
||||
) {
|
||||
await go("/activate", mode);
|
||||
await fillActivation();
|
||||
await page.getByRole("alert").waitFor();
|
||||
assertEquals(await page.getByText(/You are signed in/).count(), 0);
|
||||
await noOverflow();
|
||||
}
|
||||
await go("/activate", "activation-success");
|
||||
await fillActivation();
|
||||
await page.getByText(
|
||||
"Passkey registration and activation confirmed for alice. You are signed in.",
|
||||
).waitFor();
|
||||
assertEquals(await page.locator(".app-shell").count(), 0);
|
||||
await noOverflow();
|
||||
assertEquals(
|
||||
requested.some((path) => path.includes("/api/workspaces")),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
requested.some((path) =>
|
||||
path.includes("/addition/") || path.includes("/registration/") ||
|
||||
path.includes("/device")
|
||||
),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
await page.evaluate(() =>
|
||||
[...Object.values(localStorage), ...Object.values(sessionStorage)]
|
||||
.some((value) => value.includes("synthetic-"))
|
||||
),
|
||||
false,
|
||||
);
|
||||
assertEquals(new URL(page.url()).searchParams.has("code"), false);
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// 合成HTTPとWebAuthn境界だけを使い、本番build/shellをshared web-uxで採取する。
|
||||
// ServerE2Eとは別物。secret fieldは撮影前に隠し、snapshot/診断もredactする。
|
||||
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!, "../../../..");
|
||||
Deno.env.set("WEB_UX_SYNTHETIC_ACTIVATION_INPUT", "synthetic-input-only");
|
||||
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 activation = [
|
||||
fill("#activation-handle", "alice"),
|
||||
fill("#activation-code", "${WEB_UX_SYNTHETIC_ACTIVATION_INPUT}"),
|
||||
click('button:has-text("Register Passkey and activate")'),
|
||||
];
|
||||
const route = (
|
||||
id: string,
|
||||
path: string,
|
||||
selector: string,
|
||||
capturePoints: RouteScenario["capturePoints"] = [{
|
||||
id: "initial",
|
||||
label: id,
|
||||
fullPage: true,
|
||||
}],
|
||||
): RouteScenario => ({
|
||||
id,
|
||||
label: id,
|
||||
path,
|
||||
goal: "Check production shell and state/copy; not Server authorization.",
|
||||
dataState: `Synthetic ${id}. No real code, account, credential or session.`,
|
||||
ready: ready(selector),
|
||||
capturePoints,
|
||||
});
|
||||
const routes: RouteScenario[] = [
|
||||
route(
|
||||
"settings",
|
||||
"/hosting/settings?fixture=admin",
|
||||
'a[href="/hosting/accounts"]',
|
||||
),
|
||||
route("admin", "/hosting/accounts?fixture=admin", "#account-handle:enabled", [
|
||||
{ id: "initial", label: "ID-only creation", fullPage: true },
|
||||
{
|
||||
id: "link-focus",
|
||||
label: "Hosting settings link keyboard focus",
|
||||
interaction: [{ action: "press", selector: "summary", key: "Tab" }],
|
||||
ready: ready('a[href="/hosting/settings"]:focus-visible'),
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "issued",
|
||||
label: "Issued; code redacted",
|
||||
interaction: [
|
||||
fill("#account-handle", "alice"),
|
||||
click('button:has-text("Create account and issue code")'),
|
||||
],
|
||||
ready: ready("#issued-heading"),
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "reissue",
|
||||
label: "Explicit reissue and invalidation warning",
|
||||
interaction: [click('button:has-text("Hide code")'), {
|
||||
action: "press",
|
||||
selector: "#account-operation",
|
||||
key: "ArrowDown",
|
||||
}],
|
||||
ready: ready('button:has-text("Reissue activation code")'),
|
||||
fullPage: true,
|
||||
},
|
||||
]),
|
||||
route(
|
||||
"anonymous",
|
||||
"/hosting/accounts?fixture=anonymous",
|
||||
'a[href="/account"]',
|
||||
),
|
||||
route(
|
||||
"ordinary",
|
||||
"/hosting/accounts?fixture=ordinary",
|
||||
'p:has-text("Hosting administrator access is required.")',
|
||||
),
|
||||
route("loading", "/hosting/accounts?fixture=loading", '[role="status"]'),
|
||||
route(
|
||||
"access-error",
|
||||
"/hosting/accounts?fixture=access-error",
|
||||
'[role="alert"]',
|
||||
),
|
||||
route(
|
||||
"issue-error",
|
||||
"/hosting/accounts?fixture=issue-error",
|
||||
"#account-handle:enabled",
|
||||
[{
|
||||
id: "unconfirmed",
|
||||
label: "Intentional HTTP409; unconfirmed issuance",
|
||||
interaction: [
|
||||
fill("#account-handle", "alice"),
|
||||
click('button:has-text("Create account and issue code")'),
|
||||
],
|
||||
ready: ready('[role="alert"]'),
|
||||
fullPage: true,
|
||||
}],
|
||||
),
|
||||
route(
|
||||
"activation-pending",
|
||||
"/activate?fixture=activation-pending",
|
||||
"#activation-code:enabled",
|
||||
[
|
||||
{
|
||||
id: "initial",
|
||||
label: "ID and code before mandatory Passkey",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "link-focus",
|
||||
label: "Account recovery link keyboard focus",
|
||||
interaction: [{
|
||||
action: "press",
|
||||
selector: "summary",
|
||||
key: "Shift+Tab",
|
||||
}],
|
||||
ready: ready('a[href="/account"]:focus-visible'),
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "pending",
|
||||
label: "Pending prompt, no activation claim",
|
||||
interaction: activation,
|
||||
ready: ready('button:has-text("Cancel registration")'),
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "cancelled",
|
||||
label: "Cancelled; code cleared and restart guidance",
|
||||
interaction: [click('button:has-text("Cancel registration")')],
|
||||
ready: ready('[role="alert"]'),
|
||||
fullPage: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
...[
|
||||
"activation-cancel",
|
||||
"activation-unknown",
|
||||
"activation-reject",
|
||||
"activation-success",
|
||||
].map((mode) =>
|
||||
route(mode, `/activate?fixture=${mode}`, "#activation-code:enabled", [{
|
||||
id: "result",
|
||||
label: mode,
|
||||
interaction: activation,
|
||||
ready: ready(
|
||||
mode === "activation-success" ? '[role="status"]' : '[role="alert"]',
|
||||
),
|
||||
fullPage: true,
|
||||
}])
|
||||
),
|
||||
];
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const scenario: Scenario = {
|
||||
schemaVersion: 1,
|
||||
id: `activation-${colorScheme}-${width}`,
|
||||
title: "Activation synthetic projection on production build",
|
||||
baseUrl: "http://127.0.0.1:15382",
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
personas: [{
|
||||
id: "fixture",
|
||||
label: "Synthetic HTTP/WebAuthn only",
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [{ width, height: 900, label: `${width}` }],
|
||||
routes,
|
||||
redact: {
|
||||
selectors: [".secret"],
|
||||
text: ["synthetic-output-only", "${WEB_UX_SYNTHETIC_ACTIVATION_INPUT}"],
|
||||
},
|
||||
processes: [{
|
||||
id: "activation-projection",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/activation-browser/fixture-server.ts",
|
||||
"15382",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15382/health",
|
||||
}],
|
||||
};
|
||||
const scenarioPath =
|
||||
`${root}/target/web-ux/activation-${colorScheme}-${width}.json`;
|
||||
await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2));
|
||||
const result = await capture({
|
||||
scenarioPath,
|
||||
outputDirectory: `${root}/target/web-ux`,
|
||||
runId: `activation-after-v3-${colorScheme}-${width}`,
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
colorScheme,
|
||||
width,
|
||||
status: result.status,
|
||||
captures: result.captures.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// 本番buildを配信するUI投影fixture。実際のcode検証、認証、DB、cookie発行ではない。
|
||||
// request本文/credential/secretを記録しない。fixture cookieは公開状態名だけ。
|
||||
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 build root");
|
||||
const root = resolve(build);
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".woff2": "font/woff2",
|
||||
".png": "image/png",
|
||||
};
|
||||
const user = {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
};
|
||||
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
const selected = url.searchParams.get("fixture");
|
||||
const mode = selected ??
|
||||
request.headers.get("cookie")?.match(/activation-fixture=([\w-]+)/)?.[1] ??
|
||||
"admin";
|
||||
const path = url.pathname;
|
||||
if (path === "/__ui_fixture/webauthn.js") {
|
||||
const source = mode === "activation-pending"
|
||||
? `Object.defineProperty(navigator.credentials,'create',{configurable:true,value:async()=>new Promise(()=>{})});`
|
||||
: mode === "activation-cancel"
|
||||
? `Object.defineProperty(navigator.credentials,'create',{configurable:true,value:async()=>{throw new DOMException('Cancelled','NotAllowedError')}});`
|
||||
: setupWebAuthnProjection;
|
||||
return new Response(source, {
|
||||
headers: { "content-type": "text/javascript" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/workspaces") return new Response(null, { status: 503 });
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: mode === "anonymous"
|
||||
? null
|
||||
: { ...user, auth_method: "browser_session" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
if (mode === "loading") return await new Promise<Response>(() => {});
|
||||
if (mode === "access-error") return new Response(null, { status: 503 });
|
||||
return Response.json({
|
||||
is_admin: mode !== "ordinary" && mode !== "anonymous",
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/settings") {
|
||||
return Response.json({ self_registration_enabled: false });
|
||||
}
|
||||
if (
|
||||
path === "/api/hosting/accounts" ||
|
||||
path === "/api/hosting/accounts/activation-code"
|
||||
) {
|
||||
if (mode === "issue-error") return new Response(null, { status: 409 });
|
||||
// codeは暗号素材でない固定の投影値。captureは.secretを隠しsnapshotからも除外する。
|
||||
return Response.json({
|
||||
user,
|
||||
code: "synthetic-output-only",
|
||||
expires_at: new Date(Date.now() + 900000).toISOString(),
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/activation/options") {
|
||||
if (mode === "activation-reject") {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
return Response.json({
|
||||
challenge_id: "ui-projection-only",
|
||||
public_key: {
|
||||
challenge: "dWk",
|
||||
rp: { name: "UI fixture" },
|
||||
user: { id: "dWk", name: "alice", displayName: "Alice" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/activation/complete") {
|
||||
if (mode === "activation-unknown") {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
return Response.json({ user });
|
||||
}
|
||||
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
|
||||
const file = join(root, decodeURIComponent(path));
|
||||
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 (selected) {
|
||||
headers.set(
|
||||
"set-cookie",
|
||||
`activation-fixture=${selected}; Path=/; SameSite=Strict`,
|
||||
);
|
||||
}
|
||||
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
|
||||
});
|
||||
@@ -101,10 +101,10 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
|
||||
await page.keyboard.press("Enter");
|
||||
const edit = page.getByRole("button", { name: "Edit registration policy" });
|
||||
await edit.waitFor();
|
||||
assertEquals(
|
||||
await page.locator('a[href="/hosting/settings"]').getAttribute("aria-current"),
|
||||
"page",
|
||||
);
|
||||
// Globalの入口からhostingへ進んだ後はWorkspace shellを離れる。
|
||||
// Workspace未作成でも管理できることと整合させる。
|
||||
assertEquals(await nav.count(), 0);
|
||||
await page.getByRole("heading", { name: "Hosting settings", exact: true }).waitFor();
|
||||
if (
|
||||
await page.getByRole("button", { name: "Hide sidebar", exact: true }).isVisible() &&
|
||||
width < 768
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import { assertEquals } from "jsr:@std/assert";
|
||||
import { ssr } from "../src/routes/+layout.ts";
|
||||
import { load, ssr } from "../src/routes/+layout.ts";
|
||||
|
||||
Deno.test("activation and hosting shells never require Workspace catalog authority", async () => {
|
||||
for (
|
||||
const path of [
|
||||
"/activate",
|
||||
"/hosting/accounts",
|
||||
"/hosting/settings",
|
||||
"/hosting/setup",
|
||||
]
|
||||
) {
|
||||
let calls = 0;
|
||||
const event = {
|
||||
url: new URL(path, "https://workspace.test"),
|
||||
fetch: () => {
|
||||
calls++;
|
||||
throw new Error("Workspace API must not be called");
|
||||
},
|
||||
} as unknown as Parameters<typeof load>[0];
|
||||
assertEquals(await load(event).deferredCatalog, {
|
||||
ok: true,
|
||||
data: { accessibleWorkspaces: [], workspaceCatalogError: null },
|
||||
});
|
||||
assertEquals(calls, 0);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("workspace frontend serves deep links through the client SPA", () => {
|
||||
assertEquals(ssr, false);
|
||||
|
||||
Reference in New Issue
Block a user