feat: explicit hosting administrators and persistent self-registration policy
This commit is contained in:
@@ -6,8 +6,9 @@
|
||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:hosting": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
|
||||
"test:drive:real-api": "cd ../.. && cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored",
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
export type AuthPublicConfig = { rp_id: string, origin: string, public_base_url: string, cookie_name: string, };
|
||||
|
||||
export type HostingSettings = { self_registration_enabled: boolean, };
|
||||
|
||||
export type HostingAccess = { is_admin: boolean, };
|
||||
|
||||
export type ActorAuthMethod = "browser_session" | "api_token";
|
||||
|
||||
export type AuthenticatedUser = { user_id: string, account_id: string, handle: string, display_name: string, };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
|
||||
|
||||
import { enrollHostingPasskey } from "./hosting-enrollment.ts";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
registrationOptions,
|
||||
} from "./api.test-fixtures.ts";
|
||||
|
||||
function equal(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error("Values differ");
|
||||
}
|
||||
}
|
||||
async function rejects(run: Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await run;
|
||||
} catch (error) {
|
||||
equal(
|
||||
error instanceof Error && error.message,
|
||||
"Passkey enrollment could not be confirmed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
}
|
||||
|
||||
// 本番clientとWebAuthn変換を接続し、fetchとbrowser credential境界だけを代替する。
|
||||
// ServerのBearer認証・署名・cookie発行を保証する試験ではない。
|
||||
async function boundary(
|
||||
fetcher: typeof fetch,
|
||||
run: () => Promise<void>,
|
||||
create: (options: CredentialCreationOptions) => Promise<Credential | null> =
|
||||
async () => credentialFixture(),
|
||||
) {
|
||||
const descriptors = new Map(
|
||||
["fetch", "window", "navigator"].map((
|
||||
key,
|
||||
) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]),
|
||||
);
|
||||
Object.defineProperties(globalThis, {
|
||||
fetch: { configurable: true, writable: true, value: fetcher },
|
||||
window: {
|
||||
configurable: true,
|
||||
value: {
|
||||
isSecureContext: true,
|
||||
location: { origin: "https://yoi.example" },
|
||||
},
|
||||
},
|
||||
navigator: { configurable: true, value: { credentials: { create } } },
|
||||
});
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
for (const [key, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("host enrollment sends bearer only to same-origin addition endpoints, never registration", async () => {
|
||||
const requests: { path: string; init: RequestInit }[] = [];
|
||||
const controller = new AbortController();
|
||||
await boundary(async (input, init) => {
|
||||
requests.push({ path: String(input), init: init! });
|
||||
return Response.json(
|
||||
String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: { user: authUser },
|
||||
);
|
||||
}, async () => {
|
||||
equal(
|
||||
await enrollHostingPasskey("host-test-token", controller.signal),
|
||||
authUser,
|
||||
);
|
||||
equal(requests.map((request) => request.path), [
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
]);
|
||||
for (const { init } of requests) {
|
||||
equal(init.method, "POST");
|
||||
equal(init.credentials, "omit");
|
||||
equal(init.mode, "same-origin");
|
||||
equal(init.redirect, "error");
|
||||
equal(init.cache, "no-store");
|
||||
equal(init.referrerPolicy, "no-referrer");
|
||||
equal(
|
||||
new Headers(init.headers).get("authorization"),
|
||||
"Bearer host-test-token",
|
||||
);
|
||||
equal(String(init.body).includes("host-test-token"), false);
|
||||
}
|
||||
equal(JSON.parse(String(requests[0].init.body)), {
|
||||
browser_origin: "https://yoi.example",
|
||||
});
|
||||
const complete = JSON.parse(String(requests[1].init.body));
|
||||
equal(Object.keys(complete).sort(), ["challenge_id", "credential"]);
|
||||
equal(complete.challenge_id, registrationOptions.challenge_id);
|
||||
equal(complete.credential.response.attestationObject, "AQID");
|
||||
}, async (options) => {
|
||||
equal([...new Uint8Array(options.publicKey!.challenge as ArrayBuffer)], [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
]);
|
||||
equal([...new Uint8Array(options.publicKey!.user.id as ArrayBuffer)], [
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
]);
|
||||
equal(options.signal === controller.signal, true);
|
||||
return credentialFixture();
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("host enrollment refuses missing, oversized and header-injecting tokens before network", async () => {
|
||||
let calls = 0;
|
||||
await boundary(async () => {
|
||||
calls++;
|
||||
return Response.json({});
|
||||
}, async () => {
|
||||
for (const token of ["", " ", "x\r\ny", "x".repeat(4097)]) {
|
||||
await rejects(enrollHostingPasskey(token, new AbortController().signal));
|
||||
}
|
||||
equal(calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("host enrollment sanitizes rejection and thrown diagnostics without retry", async () => {
|
||||
for (const failure of [401, 403, 503, "throw"] as const) {
|
||||
let calls = 0;
|
||||
await boundary(async () => {
|
||||
calls++;
|
||||
if (failure === "throw") throw new Error("host-test-token in diagnostic");
|
||||
return new Response("host-test-token", { status: failure });
|
||||
}, async () => {
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(calls, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("host enrollment validates options before opening credential prompt", async () => {
|
||||
for (
|
||||
const payload of [{}, { ...registrationOptions, challenge_id: "" }, {
|
||||
...registrationOptions,
|
||||
unexpected: true,
|
||||
}]
|
||||
) {
|
||||
let calls = 0;
|
||||
await boundary(async () => Response.json(payload), async () => {
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(calls, 0);
|
||||
}, async () => {
|
||||
calls++;
|
||||
return credentialFixture();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("host enrollment cancellation and teardown prevent completion", async () => {
|
||||
for (const abort of [false, true]) {
|
||||
const controller = new AbortController();
|
||||
let requests = 0;
|
||||
await boundary(async () => {
|
||||
requests++;
|
||||
return Response.json(registrationOptions);
|
||||
}, async () => {
|
||||
await rejects(enrollHostingPasskey("host-test-token", controller.signal));
|
||||
equal(requests, 1);
|
||||
}, async () => {
|
||||
if (abort) {
|
||||
controller.abort();
|
||||
return credentialFixture();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("host enrollment cannot confirm a rejected, lost or malformed completion and never retries", async () => {
|
||||
for (const mode of ["rejected", "lost", "malformed", "oversized"] as const) {
|
||||
let calls = 0;
|
||||
await boundary(async (input) => {
|
||||
calls++;
|
||||
if (String(input).endsWith("/options")) {
|
||||
return Response.json(registrationOptions);
|
||||
}
|
||||
if (mode === "lost") throw new Error("host-test-token");
|
||||
if (mode === "rejected") return new Response(null, { status: 401 });
|
||||
if (mode === "oversized") {
|
||||
return new Response("{}", { headers: { "content-length": "262145" } });
|
||||
}
|
||||
return Response.json({ user: { handle: "host-test-token" } });
|
||||
}, async () => {
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(calls, 2);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("host enrollment sends no token on an insecure browser origin", async () => {
|
||||
let requests = 0;
|
||||
await boundary(async () => {
|
||||
requests++;
|
||||
return Response.json({});
|
||||
}, async () => {
|
||||
Object.defineProperty(window, "isSecureContext", { value: false });
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(requests, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
PasskeyAdditionOptionsRequest,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
} from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import {
|
||||
type AuthUser,
|
||||
parseAuthUserResponse,
|
||||
prepareRegistrationOptions,
|
||||
registrationCredentialToJson,
|
||||
} from "./model.ts";
|
||||
|
||||
// Host発行secretはこの操作のmemoryだけに置く。汎用API clientや永続sessionへ渡さない。
|
||||
// Cookieが既にあっても、本人の選択はServerがBearerから導出する。
|
||||
export async function enrollHostingPasskey(
|
||||
token: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
try {
|
||||
// WebAuthnが使えない非secure contextへsecretを送る前に拒否する。
|
||||
if (window.isSecureContext !== true) {
|
||||
throw new Error("Secure context required");
|
||||
}
|
||||
if (!/^[A-Za-z0-9._~-]{1,4096}$/.test(token)) {
|
||||
throw new Error("Invalid setup token");
|
||||
}
|
||||
const request = async (
|
||||
path:
|
||||
| "/api/auth/passkeys/addition/options"
|
||||
| "/api/auth/passkeys/addition/complete",
|
||||
body: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
|
||||
): Promise<unknown> => {
|
||||
signal.throwIfAborted();
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
// Bearerの失効を既存cookieで補わない。設定用tokenだけで本人を決め、
|
||||
// completionのcookieも受け取らず、次の明示Passkey loginでsessionを作る。
|
||||
credentials: "omit",
|
||||
mode: "same-origin",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
referrerPolicy: "no-referrer",
|
||||
signal,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error("Setup request rejected");
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
};
|
||||
const options = await request("/api/auth/passkeys/addition/options", {
|
||||
browser_origin: window.location.origin,
|
||||
});
|
||||
const publicKey = prepareRegistrationOptions(options);
|
||||
// prepareRegistrationOptionsでchallenge_idも含めて検証済み。
|
||||
const challengeId = (options as { challenge_id: string }).challenge_id;
|
||||
signal.throwIfAborted();
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey,
|
||||
signal,
|
||||
});
|
||||
if (!credential) throw new Error("Credential creation cancelled");
|
||||
signal.throwIfAborted();
|
||||
const result = await request("/api/auth/passkeys/addition/complete", {
|
||||
challenge_id: challengeId,
|
||||
credential: registrationCredentialToJson(credential),
|
||||
});
|
||||
return parseAuthUserResponse(result).user;
|
||||
} catch {
|
||||
// fetch/WebAuthn/responseの例外にはsecretを含む可能性があるため転載しない。
|
||||
throw new Error("Passkey enrollment could not be confirmed.");
|
||||
} finally {
|
||||
token = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertThrows(fn: () => unknown): void {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
}
|
||||
|
||||
async function assertRejects(
|
||||
fn: () => Promise<unknown>,
|
||||
message: string,
|
||||
): Promise<Error> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes(message)) return error;
|
||||
throw new Error("Unexpected rejection");
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
}
|
||||
import {
|
||||
HostingRequestError,
|
||||
loadHostingAccess,
|
||||
loadHostingSettings,
|
||||
loadRegistrationPolicy,
|
||||
parseHostingAccess,
|
||||
parseHostingSettings,
|
||||
saveHostingSettings,
|
||||
} from "./hosting.ts";
|
||||
|
||||
Deno.test("hosting projections accept only an exact boolean field", () => {
|
||||
for (const enabled of [true, false]) {
|
||||
assertEquals(parseHostingSettings({ self_registration_enabled: enabled }), {
|
||||
self_registration_enabled: enabled,
|
||||
});
|
||||
assertEquals(parseHostingAccess({ is_admin: enabled }), {
|
||||
is_admin: enabled,
|
||||
});
|
||||
}
|
||||
for (
|
||||
const value of [
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
true,
|
||||
{ self_registration_enabled: "true" },
|
||||
{ self_registration_enabled: 1 },
|
||||
{ self_registration_enabled: true, secret: "unexpected" },
|
||||
{ is_admin: true },
|
||||
]
|
||||
) {
|
||||
assertThrows(() => parseHostingSettings(value));
|
||||
}
|
||||
for (
|
||||
const value of [null, [], {}, { is_admin: "true" }, {
|
||||
is_admin: true,
|
||||
user: "admin",
|
||||
}, { self_registration_enabled: true }]
|
||||
) {
|
||||
assertThrows(() => parseHostingAccess(value));
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("hosting adapters use distinct public/access/admin endpoints and same-origin credentials", async () => {
|
||||
const original = globalThis.fetch;
|
||||
const requests: { path: string; init?: RequestInit }[] = [];
|
||||
globalThis.fetch = ((input, init) => {
|
||||
const path = String(input);
|
||||
requests.push({ path, init });
|
||||
return Promise.resolve(
|
||||
Response.json(
|
||||
path.endsWith("/access")
|
||||
? { is_admin: false }
|
||||
: { self_registration_enabled: false },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
assertEquals(await loadRegistrationPolicy(), {
|
||||
self_registration_enabled: false,
|
||||
});
|
||||
assertEquals(await loadHostingAccess(), { is_admin: false });
|
||||
assertEquals(await loadHostingSettings(), {
|
||||
self_registration_enabled: false,
|
||||
});
|
||||
assertEquals(
|
||||
await saveHostingSettings({ self_registration_enabled: false }),
|
||||
{ self_registration_enabled: false },
|
||||
);
|
||||
assertEquals(requests.map((item) => item.path), [
|
||||
"/api/auth/registration-policy",
|
||||
"/api/hosting/access",
|
||||
"/api/hosting/settings",
|
||||
"/api/hosting/settings",
|
||||
]);
|
||||
assertEquals(
|
||||
requests.map((item) => item.init?.credentials),
|
||||
Array(4).fill("same-origin"),
|
||||
);
|
||||
assertEquals(
|
||||
requests.map((item) => item.init?.cache),
|
||||
Array(4).fill("no-store"),
|
||||
);
|
||||
assertEquals(requests[3].init?.method, "PUT");
|
||||
assertEquals(JSON.parse(String(requests[3].init?.body)), {
|
||||
self_registration_enabled: false,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("hosting failures expose status without response bodies and reject malformed success", async () => {
|
||||
const original = globalThis.fetch;
|
||||
try {
|
||||
for (const status of [401, 403, 503]) {
|
||||
globalThis.fetch = () =>
|
||||
Promise.resolve(new Response("private diagnostic", { status }));
|
||||
const error = await assertRejects(
|
||||
loadHostingSettings,
|
||||
`Hosting request failed (${status}).`,
|
||||
);
|
||||
if (!(error instanceof HostingRequestError)) {
|
||||
throw new Error("Expected HTTP status error");
|
||||
}
|
||||
assertEquals(error.status, status);
|
||||
}
|
||||
globalThis.fetch = () =>
|
||||
Promise.resolve(Response.json({ is_admin: "true" }));
|
||||
await assertRejects(loadHostingAccess, "Invalid hosting response");
|
||||
globalThis.fetch = () => Promise.resolve(new Response("invalid JSON"));
|
||||
await assertRejects(loadRegistrationPolicy, "not valid JSON");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
HostingAccess,
|
||||
HostingSettings,
|
||||
} from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
|
||||
export class HostingRequestError extends Error {
|
||||
constructor(readonly status: number) {
|
||||
super(`Hosting request failed (${status}).`);
|
||||
}
|
||||
}
|
||||
|
||||
function booleanRecord(value: unknown, key: string): Record<string, boolean> {
|
||||
if (
|
||||
value === null || typeof value !== "object" || Array.isArray(value) ||
|
||||
Object.keys(value).length !== 1 ||
|
||||
!Object.hasOwn(value, key) ||
|
||||
typeof (value as Record<string, unknown>)[key] !== "boolean"
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid hosting response: expected only ${key} as a boolean.`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function parseHostingSettings(value: unknown): HostingSettings {
|
||||
return {
|
||||
self_registration_enabled: booleanRecord(value, "self_registration_enabled")
|
||||
.self_registration_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseHostingAccess(value: unknown): HostingAccess {
|
||||
return { is_admin: booleanRecord(value, "is_admin").is_admin };
|
||||
}
|
||||
|
||||
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init?.headers },
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new HostingRequestError(response.status);
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
}
|
||||
|
||||
export async function loadRegistrationPolicy(): Promise<HostingSettings> {
|
||||
return parseHostingSettings(
|
||||
await requestJson("/api/auth/registration-policy"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadHostingAccess(): Promise<HostingAccess> {
|
||||
return parseHostingAccess(await requestJson("/api/hosting/access"));
|
||||
}
|
||||
|
||||
export async function loadHostingSettings(): Promise<HostingSettings> {
|
||||
return parseHostingSettings(await requestJson("/api/hosting/settings"));
|
||||
}
|
||||
|
||||
export async function saveHostingSettings(
|
||||
settings: HostingSettings,
|
||||
): Promise<HostingSettings> {
|
||||
return parseHostingSettings(
|
||||
await requestJson("/api/hosting/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(parseHostingSettings(settings)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Session変更は再取得の契機だけに使い、権限そのものはaccess endpointで確認する。
|
||||
export const SESSION_CHANGED_EVENT = "yoi:browser-session-changed";
|
||||
export function notifySessionChanged(): void {
|
||||
window.dispatchEvent(new Event(SESSION_CHANGED_EVENT));
|
||||
}
|
||||
@@ -1,5 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { WorkspaceCatalogRecord } from "#lib/workspace/api/workspace-catalog.ts";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { loadWhoami } from "#lib/workspace/auth/api.ts";
|
||||
import { loadHostingAccess, SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
|
||||
|
||||
let mounted = $state(false);
|
||||
let isAdmin = $state(false);
|
||||
let accessRequest = 0;
|
||||
|
||||
async function refreshAccess() {
|
||||
const request = ++accessRequest;
|
||||
isAdmin = false;
|
||||
try {
|
||||
const whoami = await loadWhoami();
|
||||
if (request !== accessRequest || !whoami.actor) return;
|
||||
const access = await loadHostingAccess();
|
||||
if (request === accessRequest) isAdmin = access.is_admin;
|
||||
} catch {
|
||||
// 未確認の権限では管理導線を表示しない。直接screenでも独立して検証する。
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const refresh = () => { void refreshAccess(); };
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => {
|
||||
++accessRequest;
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
window.removeEventListener("focus", refresh);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
currentPath;
|
||||
if (mounted) untrack(() => { void refreshAccess(); });
|
||||
});
|
||||
|
||||
type Props = {
|
||||
currentPath: string;
|
||||
@@ -37,6 +74,14 @@
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{#if isAdmin}
|
||||
<a
|
||||
class="sidebar-link"
|
||||
class:active={currentPath === "/hosting/settings"}
|
||||
href="/hosting/settings"
|
||||
aria-current={currentPath === "/hosting/settings" ? "page" : undefined}
|
||||
>Hosting settings</a>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import GlobalNavSections from "./GlobalNavSections.svelte";
|
||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
import { notifySessionChanged } from "#lib/workspace/auth/hosting.ts";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
for (
|
||||
const persona of ["anonymous", "ordinary", "admin", "unavailable"] as const
|
||||
) {
|
||||
test(`global navigation exposes hosting settings only for confirmed admin, not ${persona}`, async () => {
|
||||
let actor = persona !== "anonymous";
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
if (String(input) === "/api/hosting/access") {
|
||||
if (persona === "unavailable") {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
return Response.json({ is_admin: persona === "admin" });
|
||||
}
|
||||
throw new Error(`Unexpected API ${input}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(GlobalNavSections, {
|
||||
currentPath: "/hosting/settings",
|
||||
workspaces: [],
|
||||
});
|
||||
await waitFor(() => expect(fetcher).toHaveBeenCalled());
|
||||
if (persona === "admin") {
|
||||
const link = await screen.findByRole("link", {
|
||||
name: "Hosting settings",
|
||||
});
|
||||
expect(link.getAttribute("href")).toBe("/hosting/settings");
|
||||
expect(link.getAttribute("aria-current")).toBe("page");
|
||||
actor = false;
|
||||
notifySessionChanged();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("link", { name: "Hosting settings" }))
|
||||
.toBeNull()
|
||||
);
|
||||
} else {
|
||||
if (persona !== "anonymous") {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => path === "/api/hosting/access"),
|
||||
).toBe(true)
|
||||
);
|
||||
}
|
||||
expect(screen.queryByRole("link", { name: "Hosting settings" }))
|
||||
.toBeNull();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
} from '#lib/workspace/auth/api.ts';
|
||||
import '#lib/workspace/styles/settings.css';
|
||||
import type { RequestActor } from '#lib/workspace/auth/model.ts';
|
||||
import { loadRegistrationPolicy, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
||||
|
||||
let actor = $state<RequestActor | null>(null);
|
||||
let handle = $state('local');
|
||||
@@ -18,6 +19,25 @@
|
||||
let busy = $state(false);
|
||||
let status = $state('');
|
||||
let error = $state('');
|
||||
let policy = $state<boolean | null>(null);
|
||||
let policyLoading = $state(true);
|
||||
let policyError = $state('');
|
||||
let policyRequest = 0;
|
||||
|
||||
async function refreshPolicy() {
|
||||
const request = ++policyRequest;
|
||||
policyLoading = true;
|
||||
policy = null;
|
||||
policyError = '';
|
||||
try {
|
||||
const settings = await loadRegistrationPolicy();
|
||||
if (request === policyRequest) policy = settings.self_registration_enabled;
|
||||
} catch {
|
||||
if (request === policyRequest) policyError = 'Registration availability could not be loaded.';
|
||||
} finally {
|
||||
if (request === policyRequest) policyLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWhoami() {
|
||||
loading = true;
|
||||
@@ -27,6 +47,7 @@
|
||||
const whoami = await loadWhoami();
|
||||
actor = whoami.actor;
|
||||
sessionReady = true;
|
||||
notifySessionChanged();
|
||||
if (actor?.handle) {
|
||||
handle = actor.handle;
|
||||
displayName = actor.display_name;
|
||||
@@ -39,7 +60,7 @@
|
||||
}
|
||||
|
||||
async function register() {
|
||||
if (busy || !sessionReady) return;
|
||||
if (busy || !sessionReady || (!actor && policy !== true)) return;
|
||||
const adding = actor !== null;
|
||||
busy = true;
|
||||
status = adding ? 'Waiting for passkey addition…' : 'Waiting for new account registration…';
|
||||
@@ -55,6 +76,7 @@
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
status = '';
|
||||
if (!adding) void refreshPolicy();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -82,6 +104,8 @@
|
||||
try {
|
||||
await logout();
|
||||
actor = null;
|
||||
notifySessionChanged();
|
||||
void refreshPolicy();
|
||||
status = 'Logged out.';
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
@@ -92,6 +116,12 @@
|
||||
|
||||
onMount(() => {
|
||||
void refreshWhoami();
|
||||
void refreshPolicy();
|
||||
window.addEventListener('focus', refreshPolicy);
|
||||
return () => {
|
||||
++policyRequest;
|
||||
window.removeEventListener('focus', refreshPolicy);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -153,13 +183,25 @@
|
||||
</label>
|
||||
<label>
|
||||
Display name (new account only)
|
||||
<input bind:value={displayName} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="name" placeholder="Local User" />
|
||||
<input bind:value={displayName} disabled={busy || loading || !sessionReady || actor !== null || policy !== true} autocomplete="name" placeholder="Local User" />
|
||||
</label>
|
||||
<div class="settings-action-row">
|
||||
<button type="button" disabled={busy || loading || !sessionReady || (!actor && !handle.trim())} onclick={register}>{actor ? `Add passkey to ${actor.handle}` : 'Register new account'}</button>
|
||||
{#if actor || policy === true}
|
||||
<button type="button" disabled={busy || loading || !sessionReady || (!actor && !handle.trim())} onclick={register}>{actor ? `Add passkey to ${actor.handle}` : 'Register new account'}</button>
|
||||
{/if}
|
||||
<button type="button" disabled={busy || loading || !sessionReady || actor !== null || !handle.trim()} onclick={login}>Log in with passkey</button>
|
||||
</div>
|
||||
</form>
|
||||
{#if !actor}
|
||||
{#if policyLoading}
|
||||
<p class="muted" role="status">Checking registration availability…</p>
|
||||
{:else if policyError}
|
||||
<p class="error-message" role="alert">{policyError}</p>
|
||||
<button type="button" onclick={refreshPolicy}>Retry registration availability</button>
|
||||
{:else if policy === false}
|
||||
<p class="muted">New account registration is disabled. Existing users can still log in.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if status}
|
||||
|
||||
@@ -11,6 +11,7 @@ import Account from "./+page.svelte";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
loginOptions,
|
||||
registrationOptions,
|
||||
} from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
|
||||
@@ -20,9 +21,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
// UIから本番HTTP adapterまでを接続し、fetch/ブラウザーcredential境界だけを代替する。
|
||||
function boundary(authenticated: boolean, completeStatus = 200) {
|
||||
function boundary(
|
||||
authenticated: boolean,
|
||||
completeStatus = 200,
|
||||
policy: boolean | "error" | "loading" = true,
|
||||
) {
|
||||
const create = vi.fn(async () => credentialFixture());
|
||||
vi.stubGlobal("navigator", { credentials: { create } });
|
||||
const get = vi.fn(async () => credentialFixture(true));
|
||||
vi.stubGlobal("navigator", { credentials: { create, get } });
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
@@ -32,9 +38,20 @@ function boundary(authenticated: boolean, completeStatus = 200) {
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/registration-policy") {
|
||||
if (policy === "loading") return await new Promise<Response>(() => {});
|
||||
if (policy === "error") return new Response(null, { status: 503 });
|
||||
return Response.json({ self_registration_enabled: policy });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path.endsWith("/options")) return Response.json(registrationOptions);
|
||||
if (path.endsWith("/complete")) {
|
||||
if (completeStatus !== 200) {
|
||||
if (completeStatus === 403 && path.includes("/registration/")) {
|
||||
policy = false;
|
||||
}
|
||||
return new Response(null, { status: completeStatus });
|
||||
}
|
||||
authenticated = true;
|
||||
@@ -43,7 +60,7 @@ function boundary(authenticated: boolean, completeStatus = 200) {
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return { fetcher, create };
|
||||
return { fetcher, create, get };
|
||||
}
|
||||
|
||||
function posted(fetcher: ReturnType<typeof boundary>["fetcher"]) {
|
||||
@@ -90,7 +107,7 @@ test("anonymous existing Account page registers a new account via registration e
|
||||
});
|
||||
|
||||
test("authenticated Account locks identity fields and adds only through self-only addition endpoints", async () => {
|
||||
const { fetcher, create } = boundary(true);
|
||||
const { fetcher, create } = boundary(true, 200, false);
|
||||
render(Account);
|
||||
const button = await screen.findByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
@@ -167,14 +184,87 @@ test("unknown session never enables registration or addition", async () => {
|
||||
);
|
||||
render(Account);
|
||||
await screen.findByText("Auth request failed (503).");
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Register new account",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText(
|
||||
"User handle (new registration or login)",
|
||||
) as HTMLInputElement).disabled,
|
||||
).toBe(true);
|
||||
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Register new account" }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
for (const policy of [false, "error", "loading"] as const) {
|
||||
test(`policy ${policy} still permits the existing passkey login HTTP flow`, async () => {
|
||||
const { fetcher, get, create } = boundary(false, 200, policy);
|
||||
render(Account);
|
||||
await screen.findByText("No browser session is active.");
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Log in with passkey" }),
|
||||
);
|
||||
await screen.findByText("Logged in.");
|
||||
expect(posted(fetcher).map((request) => request.path)).toEqual([
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
]);
|
||||
expect(get).toHaveBeenCalledOnce();
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test("registration disabled during WebAuthn completion shows rejection and reloads policy without fallback", async () => {
|
||||
const { fetcher } = boundary(false, 403, true);
|
||||
render(Account);
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Register new account" }),
|
||||
);
|
||||
await screen.findByText("Auth request failed (403).");
|
||||
await screen.findByText(/New account registration is disabled/);
|
||||
expect(screen.queryByRole("button", { name: "Register new account" }))
|
||||
.toBeNull();
|
||||
expect(screen.queryByText("New account registered.")).toBeNull();
|
||||
expect(posted(fetcher).map((request) => request.path)).toEqual([
|
||||
"/api/auth/passkeys/registration/options",
|
||||
"/api/auth/passkeys/registration/complete",
|
||||
]);
|
||||
});
|
||||
|
||||
for (const policy of [false, "error", "loading"] as const) {
|
||||
test(`registration policy ${policy} hides registration without disabling login`, async () => {
|
||||
const { fetcher } = boundary(false, 200, policy);
|
||||
render(Account);
|
||||
await screen.findByText("No browser session is active.");
|
||||
if (policy === false) {
|
||||
await screen.findByText(/New account registration is disabled/);
|
||||
}
|
||||
if (policy === "error") {
|
||||
await screen.findByText("Registration availability could not be loaded.");
|
||||
}
|
||||
if (policy === "loading") {
|
||||
await screen.findByText("Checking registration availability…");
|
||||
}
|
||||
expect(screen.queryByRole("button", { name: "Register new account" }))
|
||||
.toBeNull();
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
expect(posted(fetcher)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const policy of ["error", "loading"] as const) {
|
||||
test(`policy ${policy} does not block actor-self passkey addition`, async () => {
|
||||
const { fetcher } = boundary(true, 200, policy);
|
||||
render(Account);
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Add passkey to alice" }),
|
||||
);
|
||||
await screen.findByText("Passkey added to your account.");
|
||||
expect(posted(fetcher).map((request) => request.path)).toEqual([
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import type { HostingSettings } from '#lib/generated/auth-api.ts';
|
||||
import { loadWhoami } from '#lib/workspace/auth/api.ts';
|
||||
import {
|
||||
HostingRequestError,
|
||||
loadHostingAccess,
|
||||
loadHostingSettings,
|
||||
saveHostingSettings,
|
||||
SESSION_CHANGED_EVENT,
|
||||
} from '#lib/workspace/auth/hosting.ts';
|
||||
|
||||
let viewState = $state<'loading' | 'anonymous' | 'denied' | 'error' | 'ready'>('loading');
|
||||
let settings = $state<HostingSettings | null>(null);
|
||||
let draft = $state(false);
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
let saveBlocked = $state(false);
|
||||
let error = $state('');
|
||||
let status = $state('');
|
||||
let request = 0;
|
||||
let editButton = $state<HTMLButtonElement>();
|
||||
let checkbox = $state<HTMLInputElement>();
|
||||
|
||||
function permissionFailure(err: unknown): boolean {
|
||||
if (!(err instanceof HostingRequestError)) return false;
|
||||
if (err.status !== 401 && err.status !== 403) return false;
|
||||
settings = null;
|
||||
editing = false;
|
||||
viewState = err.status === 401 ? 'anonymous' : 'denied';
|
||||
return true;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const current = ++request;
|
||||
viewState = 'loading';
|
||||
settings = null;
|
||||
editing = false;
|
||||
error = '';
|
||||
status = '';
|
||||
saveBlocked = false;
|
||||
try {
|
||||
const whoami = await loadWhoami();
|
||||
if (current !== request) return;
|
||||
if (!whoami.actor) { viewState = 'anonymous'; return; }
|
||||
const access = await loadHostingAccess();
|
||||
if (current !== request) return;
|
||||
if (!access.is_admin) { viewState = 'denied'; return; }
|
||||
const loaded = await loadHostingSettings();
|
||||
if (current !== request) return;
|
||||
settings = loaded;
|
||||
draft = loaded.self_registration_enabled;
|
||||
viewState = 'ready';
|
||||
} catch (err) {
|
||||
if (current !== request) return;
|
||||
if (!permissionFailure(err)) {
|
||||
viewState = 'error';
|
||||
error = 'Hosting settings could not be loaded.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function edit() {
|
||||
if (!settings) return;
|
||||
draft = settings.self_registration_enabled;
|
||||
editing = true;
|
||||
status = '';
|
||||
await tick();
|
||||
checkbox?.focus();
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
editing = false;
|
||||
await tick();
|
||||
editButton?.focus();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving || saveBlocked || viewState !== 'ready' || !settings) return;
|
||||
const current = request;
|
||||
saving = true;
|
||||
error = '';
|
||||
status = '';
|
||||
try {
|
||||
const saved = await saveHostingSettings({ self_registration_enabled: draft });
|
||||
if (current !== request) return;
|
||||
settings = saved;
|
||||
draft = saved.self_registration_enabled;
|
||||
editing = false;
|
||||
status = 'Hosting settings saved.';
|
||||
await tick();
|
||||
editButton?.focus();
|
||||
} catch (err) {
|
||||
if (current !== request) return;
|
||||
if (!permissionFailure(err)) {
|
||||
// 応答喪失時に未保存と断定しない。再読込後に次の保存を判断する。
|
||||
saveBlocked = true;
|
||||
error = 'Save could not be confirmed. Reload settings before making another change.';
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
const refresh = () => { void load(); };
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
return () => {
|
||||
++request;
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Hosting settings · Yoi</title></svelte:head>
|
||||
|
||||
<div class="hosting-settings">
|
||||
<h1>Hosting settings</h1>
|
||||
{#if viewState === 'loading'}
|
||||
<p role="status">Loading hosting settings…</p>
|
||||
{:else if viewState === 'anonymous'}
|
||||
<p>Sign in with a hosting administrator account to view these settings.</p>
|
||||
<a href="/account">Open Account</a>
|
||||
{:else if viewState === 'denied'}
|
||||
<p>Hosting administrator access is required.</p>
|
||||
{:else if viewState === 'error'}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
<button type="button" onclick={load}>Retry loading settings</button>
|
||||
{:else if settings}
|
||||
<section aria-labelledby="registration-heading">
|
||||
<h2 id="registration-heading">Self-registration</h2>
|
||||
<p class="muted">Applies to this hosting, across all Workspaces.</p>
|
||||
<p>Last loaded policy: <strong>{settings.self_registration_enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
||||
{#if editing}
|
||||
<form onsubmit={(event) => { event.preventDefault(); void save(); }}>
|
||||
<label class="checkbox-row">
|
||||
<input bind:this={checkbox} type="checkbox" bind:checked={draft} disabled={saving || saveBlocked} aria-describedby="registration-impact" />
|
||||
Allow new account registration
|
||||
</label>
|
||||
<p id="registration-impact" class="muted">Disabling registration does not prevent existing users from logging in or adding a passkey to their own account.</p>
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit" disabled={saving || saveBlocked || draft === settings.self_registration_enabled}>{saving ? 'Saving…' : 'Save settings'}</button>
|
||||
<button type="button" disabled={saving} onclick={cancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<button bind:this={editButton} type="button" disabled={saveBlocked} onclick={edit}>Edit registration policy</button>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
<button type="button" onclick={load}>Reload settings</button>
|
||||
{/if}
|
||||
{#if status}<p role="status">{status}</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hosting-settings, section, form { display: grid; align-content: start; gap: var(--space-4); min-width: 0; }
|
||||
.hosting-settings { max-width: 48rem; }
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font-size: var(--font-size-title); line-height: var(--line-height-title); }
|
||||
h2 { font-size: var(--font-size-body); line-height: var(--line-height-body); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.error { color: var(--danger); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.checkbox-row { display: flex; align-items: center; gap: var(--space-2); }
|
||||
input { flex: 0 0 auto; width: 1rem; height: 1rem; }
|
||||
button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: transparent; color: var(--text-strong); font: inherit; cursor: pointer; }
|
||||
button.primary { background: var(--text-strong); color: var(--bg); }
|
||||
button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import Settings from "./+page.svelte";
|
||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function boundary(
|
||||
persona: "anonymous" | "ordinary" | "admin",
|
||||
enabled = true,
|
||||
saveStatus = 200,
|
||||
) {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: persona === "anonymous"
|
||||
? null
|
||||
: { ...authUser, auth_method: "browser_session" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: persona === "admin" });
|
||||
}
|
||||
if (path === "/api/hosting/settings") {
|
||||
if (persona !== "admin") return new Response(null, { status: 403 });
|
||||
if (init?.method === "PUT") {
|
||||
if (saveStatus !== 200) {
|
||||
return new Response(null, { status: saveStatus });
|
||||
}
|
||||
enabled = JSON.parse(String(init.body)).self_registration_enabled;
|
||||
}
|
||||
return Response.json({ self_registration_enabled: enabled });
|
||||
}
|
||||
throw new Error(`Unexpected API ${path}`);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return fetcher;
|
||||
}
|
||||
|
||||
for (const persona of ["anonymous", "ordinary"] as const) {
|
||||
test(`${persona} direct hosting screen contains no settings controls or privileged requests`, async () => {
|
||||
const fetcher = boundary(persona);
|
||||
render(Settings);
|
||||
await screen.findByText(
|
||||
persona === "anonymous"
|
||||
? "Sign in with a hosting administrator account to view these settings."
|
||||
: "Hosting administrator access is required.",
|
||||
);
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => path === "/api/hosting/settings"),
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const enabled of [true, false]) {
|
||||
test(`admin reads policy ${enabled}, edits with keyboard focus, cancels and saves confirmed value`, async () => {
|
||||
const fetcher = boundary("admin", enabled);
|
||||
render(Settings);
|
||||
const edit = await screen.findByRole("button", {
|
||||
name: "Edit registration policy",
|
||||
});
|
||||
expect(screen.getByText(enabled ? "Enabled" : "Disabled")).toBeTruthy();
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
await fireEvent.click(edit);
|
||||
let checkbox = screen.getByRole("checkbox") as HTMLInputElement;
|
||||
await waitFor(() => expect(document.activeElement).toBe(checkbox));
|
||||
expect(checkbox.checked).toBe(enabled);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Save settings",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
await fireEvent.click(checkbox);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "Edit registration policy" }),
|
||||
)
|
||||
);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Edit registration policy" }),
|
||||
);
|
||||
checkbox = screen.getByRole("checkbox") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(enabled);
|
||||
await fireEvent.click(checkbox);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Save settings" }),
|
||||
);
|
||||
await screen.findByText("Hosting settings saved.");
|
||||
expect(screen.getByText(enabled ? "Disabled" : "Enabled")).toBeTruthy();
|
||||
const writes = fetcher.mock.calls.filter(([, init]) =>
|
||||
init?.method === "PUT"
|
||||
);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(JSON.parse(String(writes[0][1]?.body))).toEqual({
|
||||
self_registration_enabled: !enabled,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [401, 403, 503]) {
|
||||
test(`save rejection ${status} never claims success or automatically retries`, async () => {
|
||||
const fetcher = boundary("admin", true, status);
|
||||
render(Settings);
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Edit registration policy" }),
|
||||
);
|
||||
await fireEvent.click(screen.getByRole("checkbox"));
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Save settings" }),
|
||||
);
|
||||
if (status === 503) {
|
||||
await screen.findByText(
|
||||
"Save could not be confirmed. Reload settings before making another change.",
|
||||
);
|
||||
expect(screen.getByText("Enabled")).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Save settings",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Reload settings" }),
|
||||
);
|
||||
await screen.findByRole("button", { name: "Edit registration policy" });
|
||||
} else {
|
||||
await screen.findByText(
|
||||
status === 401
|
||||
? "Sign in with a hosting administrator account to view these settings."
|
||||
: "Hosting administrator access is required.",
|
||||
);
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
}
|
||||
expect(screen.queryByText("Hosting settings saved.")).toBeNull();
|
||||
expect(fetcher.mock.calls.filter(([, init]) => init?.method === "PUT"))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test("unconfirmed access and settings failures show no settings controls and offer retry", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(null, { status: 503 })),
|
||||
);
|
||||
render(Settings);
|
||||
await screen.findByText("Hosting settings could not be loaded.");
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Edit registration policy" }))
|
||||
.toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Retry loading settings" }))
|
||||
.toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { loadWhoami, loginWithPasskey } from '#lib/workspace/auth/api.ts';
|
||||
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
|
||||
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
||||
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
||||
|
||||
let token = $state('');
|
||||
let user = $state<AuthUser | null>(null);
|
||||
let busy = $state(false);
|
||||
let verifiedLogin = $state(false);
|
||||
let error = $state('');
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
|
||||
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
|
||||
async function verifySession(expected: AuthUser): Promise<boolean> {
|
||||
const { actor } = await loadWhoami();
|
||||
if (!active || actor?.user_id !== expected.user_id || actor.account_id !== expected.account_id || actor.auth_method !== 'browser_session') return false;
|
||||
const access = await loadHostingAccess();
|
||||
return active && access.is_admin;
|
||||
}
|
||||
|
||||
async function enroll() {
|
||||
if (busy || user || !token.trim()) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
const pending = enrollHostingPasskey(token.trim(), controller.signal);
|
||||
// 非同期操作に渡した後、form側にはsecretを残さない。
|
||||
token = '';
|
||||
try {
|
||||
const enrolled = await pending;
|
||||
if (!active) return;
|
||||
user = enrolled;
|
||||
} catch {
|
||||
if (active) error = 'Passkey enrollment could not be confirmed. The token was cleared. If you completed the passkey prompt, try signing in from Account before enrolling again. Otherwise check the token and its expiry with the host operator.';
|
||||
} finally {
|
||||
token = '';
|
||||
if (active) busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (busy || !user) return;
|
||||
const expected = user;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const signedIn = await loginWithPasskey(expected.handle);
|
||||
if (!active) return;
|
||||
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id || !await verifySession(expected)) {
|
||||
throw new Error('Login or hosting access not verified');
|
||||
}
|
||||
if (!active) return;
|
||||
verifiedLogin = true;
|
||||
notifySessionChanged();
|
||||
} catch {
|
||||
if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.';
|
||||
} finally {
|
||||
if (active) busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
active = false;
|
||||
token = '';
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Hosting setup · Yoi</title></svelte:head>
|
||||
|
||||
<div class="hosting-setup">
|
||||
<h1>Hosting setup</h1>
|
||||
{#if !user}
|
||||
<p>Use a host-issued setup token to add the first passkey to the administrator account selected by the host. Opening this page does not create an account or grant administrator access.</p>
|
||||
<details>
|
||||
<summary>Get a token on the trusted host</summary>
|
||||
<p>The host operator runs this command against the Server database, using a new handle:</p>
|
||||
<pre><code>yoi-server hosting-admin --database PATH --handle NEW --create-user</code></pre>
|
||||
<p>Use the access_token printed once by that command within 15 minutes. Keep it private; do not put it in a URL, screenshot, shared terminal log, or support message. This is not public registration or an activation code.</p>
|
||||
</details>
|
||||
<form onsubmit={(event) => { event.preventDefault(); void enroll(); }} autocomplete="off">
|
||||
<label for="host-setup-token">Host setup token</label>
|
||||
<input id="host-setup-token" type="password" bind:value={token} autocomplete="off" spellcheck="false" autocapitalize="off" maxlength="4096" disabled={busy} required aria-describedby="setup-token-help" />
|
||||
<p id="setup-token-help" class="muted">Only enter a token from your trusted host operator on this Server’s trusted HTTPS origin (or localhost). It is kept only in this page’s memory and cleared after an attempt or when leaving.</p>
|
||||
<button class="primary" type="submit" disabled={busy || !token.trim()}>{busy ? 'Adding passkey…' : 'Add administrator passkey'}</button>
|
||||
</form>
|
||||
<a href="/account">Already have a passkey? Open Account</a>
|
||||
{:else if !verifiedLogin}
|
||||
<p role="status">Passkey added for {user.handle}. Enrollment is not a passkey login.</p>
|
||||
<p>Sign in with the new passkey to verify it before opening hosting settings.</p>
|
||||
<button class="primary" type="button" disabled={busy} onclick={login}>{busy ? 'Signing in…' : 'Sign in with new passkey'}</button>
|
||||
{:else}
|
||||
<p role="status">Passkey login and hosting administrator access verified.</p>
|
||||
<a href="/hosting/settings">Open hosting settings</a>
|
||||
{/if}
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hosting-setup, form, details[open] { display: grid; align-content: start; gap: var(--space-4); min-width: 0; }
|
||||
.hosting-setup { max-width: 42rem; }
|
||||
h1, p, pre { margin: 0; }
|
||||
h1 { font-size: var(--font-size-title); line-height: var(--line-height-title); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.error { color: var(--danger); }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-mono); }
|
||||
summary { cursor: pointer; }
|
||||
input { width: 100%; min-width: 0; padding: var(--space-2); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--bg); color: var(--text-strong); font: inherit; }
|
||||
button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); font: inherit; cursor: pointer; }
|
||||
button.primary { background: var(--text-strong); color: var(--bg); }
|
||||
button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
</style>
|
||||
@@ -0,0 +1,263 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import Setup from "./+page.svelte";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
loginOptions,
|
||||
registrationOptions,
|
||||
} from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// UI→本番adapter/serializerを接続。fetchとbrowser credentialだけ代替する。
|
||||
// Backend認証・権限の拒否や署名を検証する試験ではない。
|
||||
function boundary(
|
||||
{ status = 200, admin = true, actor = true, failLogin = false } = {},
|
||||
) {
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
const create = vi.fn(async () => credentialFixture());
|
||||
const get = vi.fn(async () => credentialFixture(true));
|
||||
vi.stubGlobal("navigator", { credentials: { create, get } });
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: admin });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/options") {
|
||||
return status === 200
|
||||
? Response.json(registrationOptions)
|
||||
: new Response("host-test-token", { status });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/complete") {
|
||||
return Response.json({ user: authUser });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
return failLogin
|
||||
? new Response(null, { status: 401 })
|
||||
: Response.json({ user: authUser });
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return { fetcher, create, get };
|
||||
}
|
||||
async function enroll() {
|
||||
await fireEvent.input(screen.getByLabelText("Host setup token"), {
|
||||
target: { value: "host-test-token" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add administrator passkey" }),
|
||||
);
|
||||
}
|
||||
|
||||
test("public shell does not request or grant anything without explicit host token input", () => {
|
||||
const { fetcher, create } = boundary();
|
||||
render(Setup);
|
||||
const input = screen.getByLabelText("Host setup token") as HTMLInputElement;
|
||||
expect(input.type).toBe("password");
|
||||
expect(input.autocomplete).toBe("off");
|
||||
expect(input.getAttribute("name")).toBeNull();
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Add administrator passkey",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("enrollment clears token and omits cookies, then verifies an actual passkey login before settings", async () => {
|
||||
const { fetcher, create, get } = boundary();
|
||||
const localWrite = vi.spyOn(window.localStorage, "setItem");
|
||||
const sessionWrite = vi.spyOn(window.sessionStorage, "setItem");
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" });
|
||||
expect(fetcher.mock.calls.some(([path]) => path === "/api/hosting/access"))
|
||||
.toBe(false);
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
expect(document.body.textContent).not.toContain("host-test-token");
|
||||
expect(localWrite).not.toHaveBeenCalled();
|
||||
expect(sessionWrite).not.toHaveBeenCalled();
|
||||
const requests = fetcher.mock.calls;
|
||||
for (const [path, init] of requests) {
|
||||
expect(String(path)).not.toContain("host-test-token");
|
||||
expect(String(init?.body)).not.toContain("host-test-token");
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe(
|
||||
String(path).includes("/addition/") ? "Bearer host-test-token" : null,
|
||||
);
|
||||
}
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
const settings = await screen.findByRole("link", {
|
||||
name: "Open hosting settings",
|
||||
});
|
||||
expect(settings.getAttribute("href")).toBe("/hosting/settings");
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
|
||||
.toHaveLength(1);
|
||||
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
|
||||
.toHaveLength(1);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).includes("registration")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
for (const status of [401, 403, 503]) {
|
||||
test(`token rejection ${status} clears input and never falls back to registration`, async () => {
|
||||
const { fetcher, create } = boundary({ status });
|
||||
const log = vi.spyOn(console, "error");
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await screen.findByRole("alert");
|
||||
expect(
|
||||
(screen.getByLabelText("Host setup token") as HTMLInputElement).value,
|
||||
).toBe("");
|
||||
expect(screen.getByRole("alert").textContent).not.toContain(
|
||||
"host-test-token",
|
||||
);
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: "Sign in with new passkey" }))
|
||||
.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
for (
|
||||
const context of [{ admin: false }, { actor: false }, { failLogin: true }]
|
||||
) {
|
||||
test(`unverified login or hosting access never exposes settings: ${JSON.stringify(context)}`, async () => {
|
||||
boundary(context);
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
test("a different cookie actor cannot be accepted as the enrolled administrator", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input) === "/api/auth/whoami"
|
||||
? Response.json({
|
||||
actor: {
|
||||
...authUser,
|
||||
user_id: "other-user",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
})
|
||||
: original(input, init)
|
||||
);
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" });
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
expect(fetcher.mock.calls.some(([path]) => path === "/api/hosting/access"))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test("leaving during the passkey prompt aborts enrollment and prevents later completion", async () => {
|
||||
const { create, fetcher } = boundary();
|
||||
let resolve!: (credential: Credential) => void;
|
||||
create.mockImplementation(() =>
|
||||
new Promise<Credential>((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
const { unmount } = render(Setup);
|
||||
await enroll();
|
||||
await waitFor(() => expect(create).toHaveBeenCalledTimes(1));
|
||||
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
const signal =
|
||||
(create.mock.calls[0] as unknown as [CredentialCreationOptions])[0].signal!;
|
||||
unmount();
|
||||
expect(signal.aborted).toBe(true);
|
||||
resolve(credentialFixture());
|
||||
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
|
||||
const { unmount: unmountAgain } = render(Setup);
|
||||
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
unmountAgain();
|
||||
});
|
||||
|
||||
test("completion response loss does not claim success and points to login before another enrollment", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
if (String(input) === "/api/auth/passkeys/addition/complete") {
|
||||
throw new Error("host-test-token");
|
||||
}
|
||||
return original(input, init);
|
||||
});
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"try signing in from Account",
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).not.toContain(
|
||||
"host-test-token",
|
||||
);
|
||||
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByRole("button", { name: "Sign in with new passkey" }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("API token identity is not mistaken for a verified browser session", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input) === "/api/auth/whoami"
|
||||
? Response.json({ actor: { ...authUser, auth_method: "api_token" } })
|
||||
: original(input, init)
|
||||
);
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" });
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
});
|
||||
@@ -24,6 +24,7 @@ interface Fixture {
|
||||
workspace_id: string;
|
||||
owner_token: string;
|
||||
second_token: string;
|
||||
foreign_token: string;
|
||||
worker_entry: DriveEntry;
|
||||
}
|
||||
function assert(value: unknown, message: string): asserts value {
|
||||
@@ -164,14 +165,14 @@ try {
|
||||
"revoke must be durably visible",
|
||||
);
|
||||
}
|
||||
globalThis.fetch = authenticatedFetch(fixture.origin, fixture.second_token);
|
||||
globalThis.fetch = authenticatedFetch(fixture.origin, fixture.foreign_token);
|
||||
try {
|
||||
await listDriveGrants(ws);
|
||||
throw new Error("member must not manage grants");
|
||||
throw new Error("foreign Account must not manage grants");
|
||||
} catch (error) {
|
||||
assert(
|
||||
error instanceof DriveGrantError && error.outcome === "not_committed",
|
||||
"member grant read must be a real authorized rejection",
|
||||
"foreign Account grant read must be a real authorized rejection",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -318,7 +319,7 @@ const secondRead = await second.readText(ws, updated.entry);
|
||||
equal(
|
||||
secondRead.entry.last_mutation_id,
|
||||
updated.last_mutation_id,
|
||||
"second actor must observe same last_mutation_id",
|
||||
"second owner client must observe same last_mutation_id",
|
||||
);
|
||||
const drafts = ["# actor A draft\n", "# actor B draft\n"];
|
||||
const race = await Promise.allSettled(
|
||||
@@ -335,7 +336,7 @@ const race = await Promise.allSettled(
|
||||
equal(
|
||||
race.filter((result) => result.status === "fulfilled").length,
|
||||
1,
|
||||
"same-last_mutation_id two-actor CAS must have exactly one winner",
|
||||
"same-last_mutation_id two-client CAS must have exactly one winner",
|
||||
);
|
||||
const loser = race.find((result) => result.status === "rejected");
|
||||
assert(
|
||||
@@ -348,7 +349,7 @@ const latest = await owner.readText(ws, updated.entry);
|
||||
equal(
|
||||
latest.text,
|
||||
drafts[winnerIndex],
|
||||
"durable content must be the winning actor's draft",
|
||||
"durable content must be the winning client's draft",
|
||||
);
|
||||
// This API boundary proves no automatic retry/overwrite. UI dirty-draft
|
||||
// retention is deliberately verified by the separate browser fixture.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Hosting UIのブラウザ検証
|
||||
|
||||
production UIと合成API応答の組合せを確認する。Setupだけはbrowser API境界も`setup-projection.ts`で置換する。Backend認可・実token・暗号署名・native WebAuthnを証明する検証ではない。実sessionやsecretは持ち込まず、request headers/bodyも記録しない。
|
||||
|
||||
共有buildは読取り専用とし、画像・診断・一時領域は`target/web-ux`へ分離する。実行入口と境界の理由は各test/capture sourceに置く。自動検査の成功は、画像とreview contextの目視判定を代替しない。
|
||||
@@ -0,0 +1,113 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import type { ReviewContext } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const output = `${root}/target/web-ux/hosting`;
|
||||
async function sha(bytes: Uint8Array<ArrayBuffer>) {
|
||||
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) =>
|
||||
value.toString(16).padStart(2, "0")
|
||||
).join("");
|
||||
}
|
||||
const results = [];
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (const prefix of ["before", "after", "navigation-before", "navigation-after"]) {
|
||||
const run = `${prefix}-final-${theme}`;
|
||||
const context: ReviewContext = JSON.parse(
|
||||
await Deno.readTextFile(`${output}/${run}/review-context.json`),
|
||||
);
|
||||
assertEquals(context.diagnostics, []);
|
||||
assert(!context.diagnosticSummary.truncated);
|
||||
let screenshots = 0, expectedFailureCaptures = 0;
|
||||
for (const capture of context.captures) {
|
||||
assertEquals(capture.document.status, 200);
|
||||
assert(!capture.errorSummary.truncated);
|
||||
const expectedFailure = ["admin-load-error", "admin-save-error", "account-error"].includes(
|
||||
capture.route.id,
|
||||
);
|
||||
if (expectedFailure) {
|
||||
expectedFailureCaptures++;
|
||||
// Non-OK bodies are cancelled by production client; ERR_ABORTED is the
|
||||
// same deliberately failed response, not an unrelated network failure.
|
||||
assertEquals(capture.errors.length, 3);
|
||||
assertEquals(
|
||||
capture.errors.filter((error) => error.status === 503).length,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
capture.errors.filter((error) =>
|
||||
error.kind === "console" && error.message.includes("503")
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
capture.errors.filter((error) =>
|
||||
error.kind === "request" && error.message === "net::ERR_ABORTED"
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
} else assertEquals(capture.errors, []);
|
||||
assert(capture.snapshot !== null);
|
||||
const snapshot = await Deno.readTextFile(`${output}/${run}/${capture.snapshot.bundlePath}`);
|
||||
if (["ordinary", "anonymous"].includes(capture.route.id)) {
|
||||
assert(!snapshot.includes('button "Edit registration policy"'));
|
||||
assert(!snapshot.includes('link "Hosting settings"'));
|
||||
}
|
||||
if (capture.route.id === "ordinary-account-off") {
|
||||
assert(snapshot.includes('button "Add passkey to alice"'));
|
||||
assert(!snapshot.includes('link "Hosting settings"'));
|
||||
}
|
||||
for (const screenshot of capture.screenshots) {
|
||||
assertEquals(
|
||||
await sha(await Deno.readFile(`${output}/${run}/${screenshot.bundlePath}`)),
|
||||
screenshot.sha256,
|
||||
);
|
||||
screenshots++;
|
||||
}
|
||||
}
|
||||
assertEquals(expectedFailureCaptures, prefix === "after" ? 9 : 0);
|
||||
results.push({
|
||||
run,
|
||||
captures: context.captures.length,
|
||||
screenshots,
|
||||
expectedFailureCaptures,
|
||||
allDocuments200: true,
|
||||
unexpectedErrors: 0,
|
||||
imageHashesVerified: true,
|
||||
diagnosticTruncation: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
async function inventory(directory: string): Promise<{ path: string; sha256: string }[]> {
|
||||
const files = [];
|
||||
for await (const entry of Deno.readDir(directory)) {
|
||||
const path = `${directory}/${entry.name}`;
|
||||
if (entry.isDirectory) files.push(...await inventory(path));
|
||||
else if (entry.isFile) {
|
||||
files.push({
|
||||
path: path.slice(root.length + 1),
|
||||
sha256: await sha(await Deno.readFile(path)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return files.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
const baselineAssets = await inventory(`${root}/target/web-ux/baseline-source/web/workspace/build`);
|
||||
const currentAssets = await inventory(`${root}/web/workspace/build`);
|
||||
await Deno.writeTextFile(
|
||||
`${output}/evidence-audit.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
boundary:
|
||||
"Actual production static shells; synthetic HTTP only. No Backend authorization/persistence/WebAuthn evidence.",
|
||||
baselineSource: "git archive 7042ec7d50ca8b03b8034c33e9792f4b6ac9d48d web/workspace",
|
||||
currentSource:
|
||||
"Dirty shared worktree at same HEAD; exact served build bytes identified by inventory. Parent owns source/build provenance and final visual verdict.",
|
||||
results,
|
||||
baselineAssets,
|
||||
currentAssets,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
@@ -0,0 +1,142 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import type { ReviewContext } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const output = `${root}/target/web-ux/hosting`;
|
||||
const synthetic = `ui-projection-${crypto.randomUUID()}`;
|
||||
// 合成値はこの子processのmemory/envに限定する。argv、URL、保存証拠へ出さない。
|
||||
await Deno.mkdir(`${root}/target/web-ux/tmp`, { recursive: true });
|
||||
const audit = [];
|
||||
const includeExisting = Deno.args.includes("--existing");
|
||||
const runLabel = Deno.args.find((arg) => arg.startsWith("--run-label="))?.slice(12) ??
|
||||
String(Date.now());
|
||||
assert(/^[a-z0-9-]+$/.test(runLabel), "run label must be a safe path component");
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (
|
||||
const kind of includeExisting ? ["setup", "settings"] : ["setup"]
|
||||
) {
|
||||
const run = `${kind === "setup" ? "setup" : "after-setup"}-${runLabel}-${theme}`;
|
||||
const child = await new Deno.Command(Deno.execPath(), {
|
||||
cwd: root,
|
||||
env: { TMPDIR: `${root}/target/web-ux/tmp`, WEB_UX_SYNTHETIC_SETUP_INPUT: synthetic },
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-env",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"--allow-write=target/web-ux",
|
||||
"--allow-run",
|
||||
"--allow-sys",
|
||||
"tools/web-ux/cli.ts",
|
||||
"capture",
|
||||
"--scenario",
|
||||
`tools/web-ux/scenarios/hosting-${kind}-${theme}.json`,
|
||||
"--output",
|
||||
"target/web-ux/hosting",
|
||||
"--run-id",
|
||||
run,
|
||||
],
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}).output();
|
||||
assertEquals(child.code, 2); // 両scenarioとも意図した拒否・障害表示を含む。
|
||||
const context: ReviewContext = JSON.parse(
|
||||
await Deno.readTextFile(`${output}/${run}/review-context.json`),
|
||||
);
|
||||
assertEquals(context.diagnostics, []);
|
||||
assertEquals(context.status, "completed-with-errors");
|
||||
assertEquals(context.captures.length, kind === "setup" ? 18 : 54);
|
||||
let images = 0;
|
||||
for (const capture of context.captures) {
|
||||
assertEquals(capture.document.status, 200);
|
||||
assertEquals(capture.errorSummary.truncated, false);
|
||||
const rejected = capture.route.id === "setup-reject";
|
||||
const expected = rejected ||
|
||||
["admin-load-error", "admin-save-error", "account-error"].includes(capture.route.id);
|
||||
if (expected) {
|
||||
assert(capture.errors.every((error) =>
|
||||
error.status === (rejected ? 403 : 503) ||
|
||||
(error.kind === "console" && error.message.includes(String(rejected ? 403 : 503))) ||
|
||||
(error.kind === "request" && error.message === "net::ERR_ABORTED")
|
||||
));
|
||||
assert(capture.errors.some((error) => error.status === (rejected ? 403 : 503)));
|
||||
} else assertEquals(capture.errors, []);
|
||||
for (const image of capture.screenshots) {
|
||||
assertEquals(
|
||||
await sha(await Deno.readFile(`${output}/${run}/${image.bundlePath}`)),
|
||||
image.sha256,
|
||||
);
|
||||
images++;
|
||||
}
|
||||
}
|
||||
let scannedTextFiles = 0;
|
||||
for await (const path of files(`${output}/${run}`)) {
|
||||
if (/\.(json|md|html|log)$/.test(path)) {
|
||||
assert(
|
||||
!(await Deno.readTextFile(path)).includes(synthetic),
|
||||
"synthetic input must not appear in artifacts",
|
||||
);
|
||||
scannedTextFiles++;
|
||||
}
|
||||
}
|
||||
audit.push({
|
||||
run,
|
||||
source: context.source,
|
||||
captures: context.captures.length,
|
||||
images,
|
||||
unexpectedErrors: 0,
|
||||
allDocuments200: true,
|
||||
imageHashesVerified: true,
|
||||
syntheticInputAbsent: true,
|
||||
scannedTextFiles,
|
||||
});
|
||||
}
|
||||
}
|
||||
async function sha(bytes: Uint8Array<ArrayBuffer>) {
|
||||
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) =>
|
||||
value.toString(16).padStart(2, "0")
|
||||
).join("");
|
||||
}
|
||||
async function* files(dir: string): AsyncGenerator<string> {
|
||||
for await (const entry of Deno.readDir(dir)) {
|
||||
const path = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory) yield* files(path);
|
||||
else if (entry.isFile) yield path;
|
||||
}
|
||||
}
|
||||
async function inventory(dir: string) {
|
||||
const result = [];
|
||||
for await (const path of files(dir)) {
|
||||
result.push({
|
||||
path: path.slice(root.length + 1),
|
||||
sha256: await sha(await Deno.readFile(path)),
|
||||
});
|
||||
}
|
||||
return result.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
const diff = await new Deno.Command("git", {
|
||||
cwd: root,
|
||||
args: ["diff", "--binary", "HEAD", "--", "web/workspace/src", "web/workspace/deno.json"],
|
||||
stdout: "piped",
|
||||
stderr: "null",
|
||||
}).output();
|
||||
assert(diff.success);
|
||||
await Deno.writeTextFile(
|
||||
`${output}/setup-evidence-audit.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
boundary:
|
||||
"Production UI modules/styles unchanged. Setup-only bootstrap replaces navigator.credentials create/get; synthetic API responses. Not native WebAuthn, real token proof, authorization or persistence evidence. No request header/body logging.",
|
||||
audit,
|
||||
currentTrackedWebDiffSha256: await sha(diff.stdout),
|
||||
currentSourceFiles: await inventory(`${root}/web/workspace/src`),
|
||||
currentBuildFiles: await inventory(`${root}/web/workspace/build`),
|
||||
fixtureFiles: await inventory(import.meta.dirname!),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log("Capture diagnostics, image hashes and synthetic-input artifact scan PASS.");
|
||||
@@ -0,0 +1,111 @@
|
||||
// UI projection fixture ONLY: actual production assets, synthetic API boundary.
|
||||
// No real sessions/accounts/DB or Backend authorization claims. Cookie is a
|
||||
// public fixture selector, not a credential; it is never saved as auth state.
|
||||
import { extname, join, resolve } from "@std/path";
|
||||
import { setupProjectionResponse } from "./setup-projection.ts";
|
||||
const [port, build] = Deno.args;
|
||||
if (!port || !build) throw new Error("usage: fixture-server.ts <port> <build-root>");
|
||||
const buildRoot = 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 policies = new Map<string, boolean>();
|
||||
// 各fixture processはscenarioを逐次実行する。setupのBearer-only要求はcookieを
|
||||
// 送らないので、秘密やheadersを読まず直前の合成page条件で応答を選ぶ。
|
||||
let setupMode: string | null = null;
|
||||
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");
|
||||
if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) setupMode = selected;
|
||||
const mode = selected ?? request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ??
|
||||
(url.pathname.startsWith("/api/auth/passkeys/addition/") ? setupMode : null) ?? "admin-on";
|
||||
if (selected) policies.delete(mode);
|
||||
const setup = setupProjectionResponse(url.pathname, mode, selected !== null);
|
||||
if (setup) return setup;
|
||||
const anonymous = mode.startsWith("anonymous") || mode.startsWith("account-");
|
||||
const ordinary = mode.startsWith("ordinary");
|
||||
if (url.pathname === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: anonymous ? null : {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/workspaces") return Response.json([]);
|
||||
if (url.pathname === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: !ordinary && !anonymous });
|
||||
}
|
||||
const policy = () => ({ self_registration_enabled: policies.get(mode) ?? !mode.includes("off") });
|
||||
if (url.pathname === "/api/auth/registration-policy") {
|
||||
if (mode === "account-error") {
|
||||
return new Response("synthetic availability failure", { status: 503 });
|
||||
}
|
||||
if (mode === "account-loading") return new Promise<Response>(() => {});
|
||||
return Response.json(policy());
|
||||
}
|
||||
if (url.pathname === "/api/hosting/settings") {
|
||||
if (anonymous || ordinary) {
|
||||
return new Response("synthetic denied", { status: anonymous ? 401 : 403 });
|
||||
}
|
||||
if (mode === "admin-load-error") {
|
||||
return new Response("synthetic settings failure", { status: 503 });
|
||||
}
|
||||
if (mode === "admin-loading" || (mode === "admin-saving" && request.method === "PUT")) {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
if (mode === "admin-save-error") {
|
||||
return new Response("synthetic unconfirmed save", { status: 503 });
|
||||
}
|
||||
const data = await request.json();
|
||||
if (typeof data.self_registration_enabled !== "boolean") {
|
||||
return new Response("invalid", { status: 400 });
|
||||
}
|
||||
policies.set(mode, data.self_registration_enabled);
|
||||
}
|
||||
return Response.json(policy());
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return new Response("unexpected fixture API", { status: 404 });
|
||||
}
|
||||
const path = join(buildRoot, decodeURIComponent(url.pathname));
|
||||
if (!path.startsWith(buildRoot + "/")) return new Response("invalid path", { status: 400 });
|
||||
let bytes: Uint8Array;
|
||||
let extension = extname(path);
|
||||
try {
|
||||
bytes = await Deno.readFile(path);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound) && !(error instanceof Deno.errors.IsADirectory)) {
|
||||
throw error;
|
||||
}
|
||||
bytes = await Deno.readFile(join(buildRoot, "index.html"));
|
||||
extension = ".html";
|
||||
}
|
||||
// productionのmarkup/style/moduleを保ち、Setupだけbrowser API境界を代替する。
|
||||
// このbootstrapはnavigator.credentialsのみを置換し、formや表示を操作しない。
|
||||
if (extension === ".html" && mode.startsWith("setup-")) {
|
||||
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", `ui-fixture=${selected}; Path=/; SameSite=Strict`);
|
||||
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { resolve } from "@std/path";
|
||||
import type { Scenario } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const scenario: Scenario = JSON.parse(
|
||||
await Deno.readTextFile(`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`),
|
||||
);
|
||||
scenario.id = `hosting-baseline-${theme}`;
|
||||
scenario.title = "Before: tracked HEAD Account and global navigation; no hosting route existed";
|
||||
scenario.routes = [
|
||||
{
|
||||
id: "account-on",
|
||||
label: "Anonymous Account before",
|
||||
path: "/account?fixture=account-on",
|
||||
goal: "Compare existing Account registration/login before policy gating.",
|
||||
dataState: "Synthetic anonymous session; unchanged baseline production shell.",
|
||||
ready: { kind: "selector", selector: 'button:has-text("Register new account"):enabled' },
|
||||
capturePoints: [{ id: "initial", label: "Before anonymous Account" }],
|
||||
},
|
||||
{
|
||||
id: "admin-account",
|
||||
label: "Signed-in Account before",
|
||||
path: "/account?fixture=admin-account",
|
||||
goal: "Compare Account and global navigation before admin settings link.",
|
||||
dataState: "Synthetic signed-in session; no hosting route/link before.",
|
||||
ready: { kind: "selector", selector: 'button:has-text("Add passkey to alice")' },
|
||||
capturePoints: [{ id: "initial", label: "Before signed-in Account" }],
|
||||
},
|
||||
];
|
||||
scenario.processes![0].cwd = root;
|
||||
scenario.processes![0].args![scenario.processes![0].args!.length - 1] =
|
||||
"target/web-ux/baseline-source/web/workspace/build";
|
||||
for (const route of scenario.routes) route.capturePoints[0].fullPage = true;
|
||||
await Deno.writeTextFile(
|
||||
`${root}/target/web-ux/baseline-${theme}.json`,
|
||||
JSON.stringify(scenario, null, 2),
|
||||
);
|
||||
for (const version of ["before", "after"]) {
|
||||
const nav = structuredClone(scenario);
|
||||
nav.id = `hosting-navigation-${version}-${theme}`;
|
||||
nav.title = `${version} mobile global navigation on production shell`;
|
||||
nav.viewports = nav.viewports.filter((item) => item.width === 390);
|
||||
for (const route of nav.routes) {
|
||||
route.capturePoints = [{
|
||||
id: "navigation",
|
||||
label: "Mobile global links",
|
||||
interaction: [{ action: "click", selector: 'button[aria-label="Show sidebar"]' }],
|
||||
ready: { kind: "selector", selector: 'nav[aria-label="Global pages"]' },
|
||||
}];
|
||||
}
|
||||
if (version === "after") {
|
||||
nav.processes![0].args![nav.processes![0].args!.length - 1] = "web/workspace/build";
|
||||
}
|
||||
await Deno.writeTextFile(
|
||||
`${root}/target/web-ux/navigation-${version}-${theme}.json`,
|
||||
JSON.stringify(nav, null, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { resolve } from "@std/path";
|
||||
import type { RouteScenario, Scenario } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const selector = (selector: string) => ({ kind: "selector" as const, selector });
|
||||
const click = (selector: string) => ({ action: "click" as const, selector });
|
||||
const press = (selector: string, key: string) => ({ action: "press" as const, selector, key });
|
||||
const route = (
|
||||
id: string,
|
||||
path: string,
|
||||
ready: string,
|
||||
dataState: string,
|
||||
points = [{ id: "initial", label: "Initial state" }],
|
||||
): RouteScenario => ({
|
||||
id,
|
||||
label: id,
|
||||
path: `${path}?fixture=${id}`,
|
||||
goal: "Verify production shell UI projection, not server authorization.",
|
||||
dataState,
|
||||
ready: selector(ready),
|
||||
capturePoints: points,
|
||||
});
|
||||
const routes: RouteScenario[] = [
|
||||
...["on", "off"].map((policy) =>
|
||||
route(
|
||||
`admin-${policy}`,
|
||||
"/hosting/settings",
|
||||
'button:has-text("Edit registration policy")',
|
||||
`Synthetic admin; policy ${policy}.`,
|
||||
[
|
||||
{ id: "initial", label: "Loaded hosting-wide policy" },
|
||||
{
|
||||
id: "edit",
|
||||
label: "Keyboard edit focus",
|
||||
interaction: [press('button:has-text("Edit registration policy")', "Enter")],
|
||||
ready: selector('input[type="checkbox"]:focus'),
|
||||
},
|
||||
{
|
||||
id: "saved",
|
||||
label: "Confirmed save",
|
||||
interaction: [
|
||||
press('input[type="checkbox"]', "Space"),
|
||||
click('button:has-text("Save settings")'),
|
||||
],
|
||||
ready: selector('[role="status"]:has-text("Hosting settings saved.")'),
|
||||
},
|
||||
] as RouteScenario["capturePoints"],
|
||||
)
|
||||
),
|
||||
route(
|
||||
"ordinary",
|
||||
"/hosting/settings",
|
||||
'p:has-text("Hosting administrator access is required.")',
|
||||
"Synthetic authenticated ordinary user, no admin controls.",
|
||||
),
|
||||
route(
|
||||
"anonymous",
|
||||
"/hosting/settings",
|
||||
'a:has-text("Open Account")',
|
||||
"Synthetic anonymous visitor; sign-in guidance, no controls.",
|
||||
),
|
||||
route(
|
||||
"admin-loading",
|
||||
"/hosting/settings",
|
||||
'[role="status"]:has-text("Loading hosting settings")',
|
||||
"Settings GET intentionally held pending; shell remains usable.",
|
||||
),
|
||||
route(
|
||||
"admin-load-error",
|
||||
"/hosting/settings",
|
||||
'button:has-text("Retry loading settings")',
|
||||
"Intentional HTTP503; expected console resource error and visible alert.",
|
||||
),
|
||||
...["saving", "save-error"].map((state) =>
|
||||
route(
|
||||
`admin-${state}`,
|
||||
"/hosting/settings",
|
||||
'button:has-text("Edit registration policy")',
|
||||
state === "saving"
|
||||
? "PUT held pending; last loaded policy retained, edit controls disabled."
|
||||
: "Intentional PUT HTTP503; outcome unknown, retry blocked until reload.",
|
||||
[
|
||||
{
|
||||
id: state,
|
||||
label: state,
|
||||
interaction: [
|
||||
click('button:has-text("Edit registration policy")'),
|
||||
press('input[type="checkbox"]', "Space"),
|
||||
click('button:has-text("Save settings")'),
|
||||
],
|
||||
ready: selector(state === "saving" ? 'button:has-text("Saving…")' : '[role="alert"]'),
|
||||
},
|
||||
] as RouteScenario["capturePoints"],
|
||||
)
|
||||
),
|
||||
route(
|
||||
"account-on",
|
||||
"/account",
|
||||
'button:has-text("Register new account"):enabled',
|
||||
"Anonymous policy on; registration and login available.",
|
||||
),
|
||||
route(
|
||||
"account-off",
|
||||
"/account",
|
||||
'p:has-text("New account registration is disabled.")',
|
||||
"Anonymous policy off; no registration action, login available.",
|
||||
),
|
||||
route(
|
||||
"admin-account",
|
||||
"/account",
|
||||
'button:has-text("Add passkey to alice")',
|
||||
"Authenticated admin; own passkey addition and global settings link.",
|
||||
),
|
||||
route(
|
||||
"ordinary-account-off",
|
||||
"/account",
|
||||
'button:has-text("Add passkey to alice")',
|
||||
"Authenticated ordinary user with policy off; own passkey addition available without admin nav.",
|
||||
),
|
||||
route(
|
||||
"account-error",
|
||||
"/account",
|
||||
'button:has-text("Retry registration availability")',
|
||||
"Intentional policy HTTP503; expected console resource error and visible alert; login retained.",
|
||||
),
|
||||
route(
|
||||
"account-loading",
|
||||
"/account",
|
||||
'[role="status"]:has-text("Checking registration availability")',
|
||||
"Policy request held pending; login remains available after session loads.",
|
||||
),
|
||||
];
|
||||
for (const account of routes.filter((item) => item.path.startsWith("/account"))) {
|
||||
account.capturePoints[0].fullPage = true;
|
||||
}
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
const scenario: Scenario = {
|
||||
schemaVersion: 1,
|
||||
id: `hosting-settings-${theme}`,
|
||||
title: "Hosting settings: synthetic UI conditions on production shell",
|
||||
baseUrl: "http://127.0.0.1:15378",
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
personas: [{
|
||||
id: "fixture",
|
||||
label: "Synthetic API projection; no real authentication",
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [{ label: "desktop", width: 1440, height: 900 }, {
|
||||
label: "narrow",
|
||||
width: 768,
|
||||
height: 900,
|
||||
}, { label: "mobile", width: 390, height: 844 }],
|
||||
routes,
|
||||
processes: [{
|
||||
id: "hosting-ui-fixture",
|
||||
command: "deno",
|
||||
cwd: "../../..",
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15378",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15378/health",
|
||||
}],
|
||||
};
|
||||
await Deno.writeTextFile(
|
||||
`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`,
|
||||
JSON.stringify(scenario, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { resolve } from "@std/path";
|
||||
import type { Interaction, Scenario } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const submit: Interaction[] = [{
|
||||
action: "fill",
|
||||
selector: "#host-setup-token",
|
||||
value: "${WEB_UX_SYNTHETIC_SETUP_INPUT}",
|
||||
}, { action: "click", selector: 'button:has-text("Add administrator passkey")' }];
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const scenario: Scenario = JSON.parse(
|
||||
await Deno.readTextFile(`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`),
|
||||
);
|
||||
scenario.id = `hosting-setup-${theme}`;
|
||||
scenario.title =
|
||||
"Hosting setup production UI: synthetic HTTP and navigator.credentials boundary only";
|
||||
scenario.redact = { text: ["${WEB_UX_SYNTHETIC_SETUP_INPUT}"] };
|
||||
scenario.routes = [{
|
||||
id: "setup-flow",
|
||||
label: "Setup enrollment and login UI",
|
||||
path: "/hosting/setup?fixture=setup-flow",
|
||||
goal:
|
||||
"Separate host-token enrollment from explicit passkey login and verified settings access.",
|
||||
dataState:
|
||||
"Synthetic actor, API and non-cryptographic navigator.credentials; no real token/authentication. Test-only bootstrap changes browser API boundary, not production UI modules/styles.",
|
||||
ready: { kind: "selector", selector: "#host-setup-token:enabled" },
|
||||
capturePoints: [
|
||||
{ id: "initial", label: "Empty password field; enrollment disabled", fullPage: true },
|
||||
{
|
||||
id: "host-help",
|
||||
label: "Trusted-host instructions expanded",
|
||||
interaction: [{ action: "press", selector: "summary", key: "Enter" }],
|
||||
ready: { kind: "selector", selector: "details[open]" },
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "masked-entry",
|
||||
label: "Synthetic input password-masked; action enabled",
|
||||
interaction: [{ action: "press", selector: "summary", key: "Enter" }, submit[0]],
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: 'button:has-text("Add administrator passkey"):enabled',
|
||||
},
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "enrolled",
|
||||
label: "Enrollment is not login; explicit login handoff",
|
||||
interaction: [submit[1]],
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: 'button:has-text("Sign in with new passkey"):enabled',
|
||||
},
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "verified",
|
||||
label: "Matching actor/session/access projected verified; settings link",
|
||||
interaction: [{
|
||||
action: "press",
|
||||
selector: 'button:has-text("Sign in with new passkey")',
|
||||
key: "Enter",
|
||||
}],
|
||||
ready: { kind: "selector", selector: 'a:has-text("Open hosting settings")' },
|
||||
fullPage: true,
|
||||
},
|
||||
],
|
||||
}, {
|
||||
id: "setup-reject",
|
||||
label: "Rejected setup enrollment",
|
||||
path: "/hosting/setup?fixture=setup-reject",
|
||||
goal:
|
||||
"Show safe unconfirmed-enrollment guidance with empty cleared token and no settings action.",
|
||||
dataState:
|
||||
"Intentional addition/options HTTP403. Expected console/resource diagnostics; no real authorization exercised.",
|
||||
ready: { kind: "selector", selector: "#host-setup-token:enabled" },
|
||||
capturePoints: [{
|
||||
id: "rejected",
|
||||
label: "Rejected attempt; token cleared",
|
||||
interaction: submit,
|
||||
ready: { kind: "selector", selector: '[role="alert"]' },
|
||||
fullPage: true,
|
||||
}],
|
||||
}];
|
||||
await Deno.writeTextFile(
|
||||
`${root}/tools/web-ux/scenarios/hosting-setup-${theme}.json`,
|
||||
JSON.stringify(scenario, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/// <reference lib="dom" />
|
||||
// Production-shell browser behavior with synthetic HTTP projections only.
|
||||
// Run against prebuilt assets; never builds or uses a real session/backend.
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { chromium, type Page } from "playwright";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const evidence = `${root}/target/web-ux/hosting/interactions`;
|
||||
async function noOverflow(page: Page) {
|
||||
assertEquals(
|
||||
await page.evaluate(() => ({
|
||||
page: document.documentElement.scrollWidth > innerWidth,
|
||||
main: [...document.querySelectorAll("main")].some((main) =>
|
||||
main.scrollWidth > main.clientWidth
|
||||
),
|
||||
})),
|
||||
{ page: false, main: false },
|
||||
);
|
||||
}
|
||||
Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown save outcome on production shell", async () => {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "hosting-interactions",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15379",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15379/health",
|
||||
readyTimeoutMs: 10000,
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/hosting-settings-light.json`,
|
||||
evidence,
|
||||
[],
|
||||
);
|
||||
const browser = await chromium.launch();
|
||||
const results: unknown[] = [];
|
||||
try {
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const httpErrors: string[] = [];
|
||||
const failedRequests: { url: string; error: string }[] = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) {
|
||||
httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`);
|
||||
}
|
||||
});
|
||||
page.on(
|
||||
"requestfailed",
|
||||
(request) =>
|
||||
failedRequests.push({
|
||||
url: request.url(),
|
||||
error: request.failure()?.errorText ?? "unknown",
|
||||
}),
|
||||
);
|
||||
const settingsRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname === "/api/hosting/settings") {
|
||||
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();
|
||||
if (
|
||||
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: "Account", exact: true }).focus();
|
||||
await page.keyboard.press("Tab");
|
||||
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").evaluate((element) =>
|
||||
getComputedStyle(element).outlineStyle !== "none"
|
||||
),
|
||||
true,
|
||||
);
|
||||
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",
|
||||
);
|
||||
if (
|
||||
await page.getByRole("button", { name: "Hide sidebar", exact: true }).isVisible() &&
|
||||
width < 768
|
||||
) 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" });
|
||||
assertEquals(
|
||||
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.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");
|
||||
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.getByText("Disabled", { exact: true }).waitFor();
|
||||
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.getByText(
|
||||
mode === "ordinary"
|
||||
? "Hosting administrator access is required."
|
||||
: "Sign in with a hosting administrator account to view these settings.",
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
assertEquals(await edit.count(), 0);
|
||||
assertEquals(await checkbox.count(), 0);
|
||||
assertEquals(
|
||||
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.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 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();
|
||||
assertEquals(
|
||||
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 edit.click();
|
||||
await checkbox.press("Space");
|
||||
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(),
|
||||
true,
|
||||
);
|
||||
await noOverflow(page);
|
||||
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);
|
||||
for (
|
||||
const [mode, path, ready] of [
|
||||
[
|
||||
"admin-load-error",
|
||||
"/hosting/settings",
|
||||
'button:has-text("Retry loading settings")',
|
||||
],
|
||||
["account-error", "/account", 'button:has-text("Retry registration availability")'],
|
||||
[
|
||||
"admin-loading",
|
||||
"/hosting/settings",
|
||||
'[role="status"]:has-text("Loading hosting settings")',
|
||||
],
|
||||
[
|
||||
"account-loading",
|
||||
"/account",
|
||||
'[role="status"]:has-text("Checking registration availability")',
|
||||
],
|
||||
]
|
||||
) {
|
||||
await page.goto(`http://127.0.0.1:15379${path}?fixture=${mode}`);
|
||||
await page.locator(ready).waitFor();
|
||||
await noOverflow(page);
|
||||
}
|
||||
// Navigation may also cancel the shell's concurrent identity/access
|
||||
// reads. Only an explicit browser cancellation of these reads is expected.
|
||||
assert(
|
||||
failedRequests.every(({ url, error }) =>
|
||||
error === "net::ERR_ABORTED" && [
|
||||
"/api/hosting/settings",
|
||||
"/api/auth/registration-policy",
|
||||
"/api/auth/whoami",
|
||||
"/api/hosting/access",
|
||||
].some((path) => url.endsWith(path))
|
||||
),
|
||||
JSON.stringify(failedRequests),
|
||||
);
|
||||
assertEquals(httpErrors, [
|
||||
"503 /api/hosting/settings",
|
||||
"503 /api/hosting/settings",
|
||||
"503 /api/auth/registration-policy",
|
||||
]);
|
||||
assertEquals(consoleErrors.length, 3);
|
||||
assert(consoleErrors.every((message) => message.includes("503")));
|
||||
assertEquals(errors, []);
|
||||
results.push({
|
||||
theme,
|
||||
width,
|
||||
keyboard: "pass",
|
||||
overflow: "none",
|
||||
deniedControls: "absent",
|
||||
save: "confirmed",
|
||||
unconfirmedSave: "blocked until reload",
|
||||
realAuthorizationTested: false,
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Setupの表示条件だけを代替する。Bearerやrequest bodyは読まず、記録もしない。
|
||||
// WebAuthnは固定の非暗号credentialを返すため、本物の認証・署名検証ではない。
|
||||
export const setupWebAuthnProjection = `
|
||||
(() => {
|
||||
const bytes = () => new Uint8Array([117, 105]).buffer;
|
||||
const core = () => ({ id: 'ui-projection-only', rawId: bytes(), type: 'public-key', getClientExtensionResults: () => ({}) });
|
||||
Object.defineProperty(navigator.credentials, 'create', { configurable: true, value: async () => ({ ...core(), response: { clientDataJSON: bytes(), attestationObject: bytes(), getTransports: () => ['internal'] } }) });
|
||||
Object.defineProperty(navigator.credentials, 'get', { configurable: true, value: async () => ({ ...core(), response: { clientDataJSON: bytes(), authenticatorData: bytes(), signature: bytes(), userHandle: null } }) });
|
||||
})();
|
||||
`;
|
||||
const user = {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
};
|
||||
const stages = new Map<string, "enrolled" | "login">();
|
||||
export function setupProjectionResponse(
|
||||
path: string,
|
||||
mode: string,
|
||||
reset: boolean,
|
||||
): Response | null {
|
||||
if (!mode.startsWith("setup-")) return null;
|
||||
if (reset) stages.delete(mode);
|
||||
if (path === "/__ui_fixture/webauthn.js") {
|
||||
return new Response(setupWebAuthnProjection, {
|
||||
headers: { "content-type": "text/javascript" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: stages.get(mode) === "login" ? { ...user, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: stages.get(mode) === "login" });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/options") {
|
||||
if (mode === "setup-reject") {
|
||||
return new Response("synthetic enrollment rejection", { status: 403 });
|
||||
}
|
||||
return Response.json({
|
||||
challenge_id: "fixture-add",
|
||||
public_key: {
|
||||
challenge: "dWk",
|
||||
rp: { name: "UI projection" },
|
||||
user: { id: "dWk", name: "alice", displayName: "Alice" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/complete") {
|
||||
stages.set(mode, "enrolled");
|
||||
return Response.json({ user });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json({ challenge_id: "fixture-login", public_key: { challenge: "dWk" } });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
stages.set(mode, "login");
|
||||
return Response.json({ user });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/// <reference lib="dom" />
|
||||
// UI projectionのみ。実APIのBearer認可・WebAuthn・cookie発行は検証しない。
|
||||
// requestを観測する場合もmethod/pathだけとし、headers/bodyは取得・保存しない。
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { chromium, type Page } from "playwright";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const evidence = `${root}/target/web-ux/hosting/setup-interactions`;
|
||||
async function noOverflow(page: Page) {
|
||||
assertEquals(
|
||||
await page.evaluate(() => ({
|
||||
page: document.documentElement.scrollWidth > innerWidth,
|
||||
main: [...document.querySelectorAll("main")].some((main) =>
|
||||
main.scrollWidth > main.clientWidth
|
||||
),
|
||||
})),
|
||||
{ page: false, main: false },
|
||||
);
|
||||
}
|
||||
Deno.test("setup UI separates masked host-token enrollment, rejected attempts, explicit login and verified settings navigation", async () => {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "setup-interactions",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15380",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15380/health",
|
||||
readyTimeoutMs: 10000,
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/hosting-setup-light.json`,
|
||||
evidence,
|
||||
[],
|
||||
);
|
||||
const browser = await chromium.launch();
|
||||
const results = [];
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const pageErrors: string[] = [],
|
||||
consoleErrors: string[] = [],
|
||||
httpErrors: string[] = [],
|
||||
requestFailures: string[] = [];
|
||||
const methodsAndPaths: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error)));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) {
|
||||
httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`);
|
||||
}
|
||||
});
|
||||
page.on(
|
||||
"requestfailed",
|
||||
(request) => requestFailures.push(new URL(request.url()).pathname),
|
||||
);
|
||||
page.on(
|
||||
"request",
|
||||
(request) =>
|
||||
methodsAndPaths.push(`${request.method()} ${new URL(request.url()).pathname}`),
|
||||
);
|
||||
await page.goto("http://127.0.0.1:15380/hosting/setup?fixture=setup-flow");
|
||||
const input = page.locator("#host-setup-token");
|
||||
await input.waitFor();
|
||||
const enroll = page.getByRole("button", {
|
||||
name: "Add administrator passkey",
|
||||
exact: true,
|
||||
});
|
||||
assertEquals(await input.getAttribute("type"), "password");
|
||||
assertEquals(await enroll.isDisabled(), true);
|
||||
assert(
|
||||
!methodsAndPaths.some((entry) => entry.startsWith("POST ")),
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Open hosting settings", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await noOverflow(page);
|
||||
await page.locator("summary").focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.locator("details[open]").waitFor();
|
||||
await noOverflow(page);
|
||||
await page.keyboard.press("Enter");
|
||||
const synthetic = `ui-projection-${crypto.randomUUID()}`;
|
||||
await input.fill(synthetic);
|
||||
assertEquals(await enroll.isEnabled(), true);
|
||||
await input.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
assertEquals(
|
||||
await enroll.evaluate((element) => element === document.activeElement),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
await enroll.evaluate((element) => getComputedStyle(element).outlineStyle !== "none"),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
const login = page.getByRole("button", { name: "Sign in with new passkey", exact: true });
|
||||
await login.waitFor();
|
||||
await page.getByRole("status").filter({ hasText: "Enrollment is not a passkey login." })
|
||||
.waitFor();
|
||||
assertEquals(await input.count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Open hosting settings", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
methodsAndPaths.filter((entry) => entry.startsWith("POST ")),
|
||||
[
|
||||
"POST /api/auth/passkeys/addition/options",
|
||||
"POST /api/auth/passkeys/addition/complete",
|
||||
],
|
||||
);
|
||||
await noOverflow(page);
|
||||
await login.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const settings = page.getByRole("link", { name: "Open hosting settings", exact: true });
|
||||
await settings.waitFor();
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Passkey login and hosting administrator access verified.",
|
||||
}).waitFor();
|
||||
await noOverflow(page);
|
||||
await settings.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.getByRole("button", { name: "Edit registration policy", exact: true })
|
||||
.waitFor();
|
||||
await noOverflow(page);
|
||||
await page.goto("http://127.0.0.1:15380/hosting/setup?fixture=setup-reject");
|
||||
await input.fill(synthetic);
|
||||
await enroll.click();
|
||||
const error = page.getByRole("alert");
|
||||
await error.waitFor();
|
||||
assert(!(await input.inputValue()));
|
||||
assertEquals(await enroll.isDisabled(), true);
|
||||
assert(!(await error.innerText()).includes(synthetic));
|
||||
assertEquals(await settings.count(), 0);
|
||||
await noOverflow(page);
|
||||
assertEquals(pageErrors, []);
|
||||
assertEquals(httpErrors, ["403 /api/auth/passkeys/addition/options"]);
|
||||
assertEquals(consoleErrors.length, 1);
|
||||
assert(consoleErrors[0].includes("403"));
|
||||
assert(requestFailures.every((path) => path === "/api/auth/passkeys/addition/options"));
|
||||
results.push({
|
||||
width,
|
||||
theme: colorScheme,
|
||||
emptyDisabled: true,
|
||||
passwordMasked: true,
|
||||
keyboard: "pass",
|
||||
tokenClearedAfterRejectedAttempt: true,
|
||||
enrollmentNotLogin: true,
|
||||
explicitLoginBeforeSettings: true,
|
||||
overflow: "none",
|
||||
unexpectedErrors: 0,
|
||||
requestHeadersOrBodiesRecorded: false,
|
||||
realAuthenticationTested: false,
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user