feat: separate login registration and own account flows

This commit is contained in:
2026-10-16 09:43:57 +09:00
parent d22899d39e
commit b8b12442a0
26 changed files with 3040 additions and 632 deletions
+101 -34
View File
@@ -18,8 +18,9 @@ use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use server_api::{
AuthUserResponse, AuthenticatedUser, PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse,
PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsResponse,
ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyLoginCompleteRequest,
PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
PasskeyRegistrationOptionsResponse, WhoamiResponse,
};
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
use yoi_e2e::WorkerE2eEnvironment;
@@ -211,6 +212,26 @@ fn access(client: &Client, base: &str, artifacts: &Path, cookie: &str, is_admin:
Ok(())
}
fn browser_identity(
client: &Client,
base: &str,
artifacts: &Path,
cookie: &str,
user: &AuthenticatedUser,
) -> Result {
let whoami: WhoamiResponse = decode(send(
client
.get(format!("{base}/api/auth/whoami"))
.header(COOKIE, cookie),
"browser session rereads its own identity",
artifacts,
)?)?;
let actor = whoami.actor.ok_or("browser session is anonymous")?;
assert_eq!(actor.user(), *user);
assert_eq!(actor.auth_method, ActorAuthMethod::BrowserSession);
Ok(())
}
fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result {
let response = success(send(
client
@@ -357,14 +378,15 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
)?;
let mut original_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
let (registered, _, user_handle) = register(
let (registered, registration_cookie, user_handle) = register(
&client,
base,
artifacts,
&environment.account_handle,
&mut original_key,
)?;
// registrationのcookieは使わず、実assertionで得たlogin cookieを次の変更操作に使う。
// 登録sessionを失効させ、実assertionで得たlogin cookieだけで本人追加へ進む。
logout(&client, base, artifacts, &registration_cookie)?;
let (logged_in, cookie) = login(
&client,
base,
@@ -373,14 +395,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
&mut original_key,
)?;
assert_eq!(logged_in.user, registered.user);
let whoami: Value = decode(send(
client
.get(format!("{base}/api/auth/whoami"))
.header(COOKIE, &cookie),
"logged-in whoami",
artifacts,
)?)?;
assert_eq!(whoami["actor"]["account_id"], registered.user.account_id);
browser_identity(&client, base, artifacts, &cookie, &registered.user)?;
let workspaces: Value = decode(send(
client
.get(format!("{base}/api/workspaces"))
@@ -534,7 +549,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
"unknown field",
)?;
}
let (added, _) = session(send(
let (added, added_cookie) = session(send(
client
.post(format!("{base}/api/auth/passkeys/addition/complete"))
.header(COOKIE, &cookie)
@@ -544,6 +559,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
artifacts,
)?)?;
assert_eq!(added.user, registered.user);
browser_identity(&client, base, artifacts, &added_cookie, &registered.user)?;
rejected(
send(
client
@@ -558,6 +574,24 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
"invalid_passkey_challenge",
)?;
// cookieを消すだけでなく、追加前後のsessionがServerでも失効したことを確かめる。
logout(&client, base, artifacts, &cookie)?;
logout(&client, base, artifacts, &added_cookie)?;
for revoked_cookie in [&cookie, &added_cookie] {
rejected(
send(
client
.post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, revoked_cookie)
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
"logout prevents further self addition with the old cookie",
artifacts,
)?,
StatusCode::FORBIDDEN,
"auth_required",
)?;
}
let (new_login, new_cookie) = login(
&client,
base,
@@ -566,7 +600,8 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
&mut added_key,
)?;
assert_eq!(new_login.user, registered.user);
let (old_login, _) = login(
browser_identity(&client, base, artifacts, &new_cookie, &registered.user)?;
let (old_login, old_cookie) = login(
&client,
base,
artifacts,
@@ -577,6 +612,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
old_login.user, registered.user,
"adding a key must preserve the original key"
);
logout(&client, base, artifacts, &old_cookie)?;
let created: Value = decode(send(
client
.post(format!("{base}/api/workspaces"))
@@ -640,18 +676,32 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
"owner",
)?;
logout(&client, base, artifacts, &new_cookie)?;
let report = environment.cleanup()?;
assert!(report.cleanup_success);
assert!(!report.fixture_root_exists_after);
Ok(())
}
fn ordinary_device_login_does_not_grant_hosting_authority(
fn device_login_pending(client: &Client, base: &str, artifacts: &Path, flow: &Value) -> Result {
let pending: Value = decode(send(
client
.post(format!("{base}/api/auth/device-login/poll"))
.json(&json!({"device_code": flow["device_code"]})),
"device flow remains pending without explicit approval",
artifacts,
)?)?;
assert_eq!(pending["status"], "pending");
assert!(pending.get("access_token").is_none_or(Value::is_null));
Ok(())
}
fn ordinary_device_login_requires_explicit_approval_and_does_not_grant_hosting_authority(
client: &Client,
base: &str,
artifacts: &Path,
user: &AuthUserResponse,
cookie: &str,
key: &mut WebauthnAuthenticator<SoftPasskey>,
) -> Result {
let flow: Value = decode(send(
client
@@ -673,10 +723,16 @@ fn ordinary_device_login_does_not_grant_hosting_authority(
StatusCode::UNAUTHORIZED,
"request requires",
)?;
device_login_pending(client, base, artifacts, &flow)?;
// login APIの完了では認証だけが成立し、CLIへの権限付与はまだない。
let (logged_in, cookie) = login(client, base, artifacts, &user.user.handle, key)?;
assert_eq!(logged_in.user, user.user);
browser_identity(client, base, artifacts, &cookie, &user.user)?;
device_login_pending(client, base, artifacts, &flow)?;
for origin in [None, Some("http://localhost:5174")] {
let mut request = client
.post(format!("{base}/api/auth/device-login/approve"))
.header(COOKIE, cookie)
.header(COOKIE, &cookie)
.json(&approval);
if let Some(origin) = origin {
request = request.header(ORIGIN, origin);
@@ -691,10 +747,11 @@ fn ordinary_device_login_does_not_grant_hosting_authority(
"origin",
)?;
}
device_login_pending(client, base, artifacts, &flow)?;
let approved: Value = decode(send(
client
.post(format!("{base}/api/auth/device-login/approve"))
.header(COOKIE, cookie)
.header(COOKIE, &cookie)
.header(ORIGIN, BROWSER_ORIGIN)
.json(&approval),
"existing ordinary user approves device login while registration disabled",
@@ -713,6 +770,16 @@ fn ordinary_device_login_does_not_grant_hosting_authority(
let token = issued["access_token"]
.as_str()
.ok_or("device poll omitted bearer")?;
let whoami: WhoamiResponse = decode(send(
client
.get(format!("{base}/api/auth/whoami"))
.bearer_auth(token),
"explicit device approval issues the logged-in user's bearer",
artifacts,
)?)?;
let actor = whoami.actor.ok_or("approved device bearer is anonymous")?;
assert_eq!(actor.user(), user.user);
assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken);
let access: Value = decode(send(
client
.get(format!("{base}/api/hosting/access"))
@@ -743,6 +810,7 @@ fn ordinary_device_login_does_not_grant_hosting_authority(
)?)?;
assert_eq!(consumed["status"], "consumed");
assert!(consumed.get("access_token").is_none_or(Value::is_null));
logout(client, base, artifacts, &cookie)?;
policy(client, base, artifacts, false)
}
@@ -974,18 +1042,13 @@ fn hosting_policy_rejects_spoofing_inflight_registration_and_survives_restart()
StatusCode::FORBIDDEN,
"self_registration_disabled",
)?;
let (logged_in, cookie) = login(
ordinary_device_login_requires_explicit_approval_and_does_not_grant_hosting_authority(
&client,
&base,
&artifacts,
&ordinary.user.handle,
&ordinary,
&mut ordinary_key,
)?;
assert_eq!(logged_in.user, ordinary.user);
access(&client, &base, &artifacts, &cookie, false)?;
ordinary_device_login_does_not_grant_hosting_authority(
&client, &base, &artifacts, &logged_in, &cookie,
)?;
let (_, cookie) = login(
&client,
&base,
@@ -1528,6 +1591,18 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
artifacts,
)?)?;
for bearer in [None, Some(issue.code.as_str()), Some(challenge_id.as_str())] {
let mut request = client.get(format!("{base}/api/auth/whoami"));
if let Some(bearer) = bearer {
request = request.bearer_auth(bearer);
}
let response = activation_success(send(
request,
"activation code and challenge leave whoami anonymous",
artifacts,
)?)?;
assert!(response.headers().get(SET_COOKIE).is_none());
let whoami: WhoamiResponse = response.json()?;
assert!(whoami.actor.is_none());
let requests = [
client.get(format!("{base}/api/hosting/settings")),
client.post(format!("{base}/api/workspaces")).header(ORIGIN, BROWSER_ORIGIN)
@@ -1550,15 +1625,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
)?)?;
}
}
let polled: Value = decode(send(
client
.post(format!("{base}/api/auth/device-login/poll"))
.json(&json!({"device_code": flow["device_code"]})),
"failed pending-user approvals did not authorize device",
artifacts,
)?)?;
assert_eq!(polled["status"], "pending");
assert!(polled.get("access_token").is_none_or(Value::is_null));
device_login_pending(&client, base, artifacts, &flow)?;
// completionはchallengeのtargetを使い、本文で別accountへ切り替えられない。
for field in ["handle", "account_id", "user_id"] {
@@ -274,30 +274,12 @@
}
]
},
{
"id": "account-on",
"label": "account-on",
"path": "/account?fixture=account-on",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Anonymous policy on; registration and login available.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Register new account\"):enabled"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
},
{
"id": "account-off",
"label": "account-off",
"path": "/account?fixture=account-off",
"path": "/register?fixture=account-off",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Anonymous policy off; no registration action, login available.",
"dataState": "Anonymous policy off; registration disabled and existing login link available.",
"ready": {
"kind": "selector",
"selector": "p:has-text(\"New account registration is disabled.\")"
@@ -315,7 +297,7 @@
"label": "admin-account",
"path": "/account?fixture=admin-account",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Authenticated admin; own passkey addition and global settings link.",
"dataState": "Authenticated own account; addition available independently of registration policy. Global navigation remains on Workspace shell.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Add passkey to alice\")"
@@ -333,7 +315,7 @@
"label": "ordinary-account-off",
"path": "/account?fixture=ordinary-account-off",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Authenticated ordinary user with policy off; own passkey addition available without admin nav.",
"dataState": "Authenticated own account; addition available independently of registration policy. Global navigation remains on Workspace shell.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Add passkey to alice\")"
@@ -349,7 +331,7 @@
{
"id": "account-error",
"label": "account-error",
"path": "/account?fixture=account-error",
"path": "/register?fixture=account-error",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Intentional policy HTTP503; expected console resource error and visible alert; login retained.",
"ready": {
@@ -367,7 +349,7 @@
{
"id": "account-loading",
"label": "account-loading",
"path": "/account?fixture=account-loading",
"path": "/register?fixture=account-loading",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Policy request held pending; login remains available after session loads.",
"ready": {
@@ -274,30 +274,12 @@
}
]
},
{
"id": "account-on",
"label": "account-on",
"path": "/account?fixture=account-on",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Anonymous policy on; registration and login available.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Register new account\"):enabled"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
},
{
"id": "account-off",
"label": "account-off",
"path": "/account?fixture=account-off",
"path": "/register?fixture=account-off",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Anonymous policy off; no registration action, login available.",
"dataState": "Anonymous policy off; registration disabled and existing login link available.",
"ready": {
"kind": "selector",
"selector": "p:has-text(\"New account registration is disabled.\")"
@@ -315,7 +297,7 @@
"label": "admin-account",
"path": "/account?fixture=admin-account",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Authenticated admin; own passkey addition and global settings link.",
"dataState": "Authenticated own account; addition available independently of registration policy. Global navigation remains on Workspace shell.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Add passkey to alice\")"
@@ -333,7 +315,7 @@
"label": "ordinary-account-off",
"path": "/account?fixture=ordinary-account-off",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Authenticated ordinary user with policy off; own passkey addition available without admin nav.",
"dataState": "Authenticated own account; addition available independently of registration policy. Global navigation remains on Workspace shell.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Add passkey to alice\")"
@@ -349,7 +331,7 @@
{
"id": "account-error",
"label": "account-error",
"path": "/account?fixture=account-error",
"path": "/register?fixture=account-error",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Intentional policy HTTP503; expected console resource error and visible alert; login retained.",
"ready": {
@@ -367,7 +349,7 @@
{
"id": "account-loading",
"label": "account-loading",
"path": "/account?fixture=account-loading",
"path": "/register?fixture=account-loading",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Policy request held pending; login remains available after session loads.",
"ready": {
+3 -2
View File
@@ -6,7 +6,7 @@
"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/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.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/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.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:activation": "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/activation-browser/activation-shell.browser.test.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",
@@ -18,7 +18,8 @@
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check",
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check"
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check",
"test:browser:auth": "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/auth-browser/auth-shell.browser.test.ts"
},
"imports": {
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@4.0.0",
@@ -0,0 +1,114 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { loginWithPasskey, registerPasskey } from './api.ts';
import { BrowserAuth } from './browser-session.svelte.ts';
import { loadRegistrationPolicy } from './hosting.ts';
import { authEntryUrl, safeReturnTo } from './routes.ts';
import './auth-page.css';
let { mode }: { mode: 'login' | 'register' } = $props();
const auth = new BrowserAuth();
let handle = $state('');
let displayName = $state('');
let policy = $state<boolean | null>(null);
let policyLoading = $state(true);
let policyError = $state('');
let policyRequest = 0;
const destination = $derived(safeReturnTo(page.url.searchParams.get('returnTo')));
async function refreshPolicy() {
if (mode !== 'register') return;
const request = ++policyRequest;
policyLoading = true; policy = null; policyError = '';
try {
const result = await loadRegistrationPolicy();
if (request === policyRequest) policy = result.self_registration_enabled;
} catch {
if (request === policyRequest) policyError = 'Registration availability could not be loaded.';
} finally {
if (request === policyRequest) policyLoading = false;
}
}
async function submit() {
if (auth.busy || auth.session.kind !== 'ready' || auth.actor || !handle.trim() ||
(mode === 'register' && (policy !== true || !displayName.trim()))) return;
const registering = mode === 'register';
const id = handle.trim();
const name = displayName.trim();
const confirmed = await auth.run(
registering ? 'Waiting for new account registration…' : 'Waiting for passkey login…',
registering ? 'New account registered.' : 'Logged in.',
(signal) => registering ? registerPasskey(id, name, signal) : loginWithPasskey(id, signal),
);
if (confirmed && auth.actor) {
await goto(destination, { replaceState: true });
} else {
if (confirmed) {
auth.status = '';
auth.error = 'A normal browser session was not confirmed. Log in again.';
}
if (registering) void refreshPolicy();
}
}
onMount(() => {
void auth.refresh();
void refreshPolicy();
window.addEventListener('focus', refreshPolicy);
return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); };
});
</script>
<svelte:head><title>{mode === 'login' ? 'Log in' : 'Create account'} · Yoi</title></svelte:head>
<div class="auth-page" data-sveltekit-preload-data="off">
<h1>{mode === 'login' ? 'Log in' : 'Create account'}</h1>
{#if auth.session.kind === 'loading'}
<p role="status" class="muted">Loading session…</p>
{:else if auth.session.kind === 'error'}
<p role="alert" class="error">Your session could not be loaded. Login status is unknown.</p>
<button type="button" disabled={auth.busy} onclick={() => auth.refresh()}>Retry session</button>
{:else if auth.actor}
<p>Logged in as <strong>{auth.actor.display_name}</strong> <span class="muted">@{auth.actor.handle}</span>.</p>
<a href={destination}>Continue to {destination === '/' ? 'dashboard / Workspaces' : 'requested page'}</a>
<a href="/account">Open Account</a>
{:else}
{#if mode === 'register'}
{#if policyLoading}
<p role="status" class="muted">Checking registration availability…</p>
{:else if policyError}
<p role="alert" class="error">{policyError}</p>
<button type="button" onclick={refreshPolicy}>Retry registration availability</button>
{:else if policy === false}
<p>New account registration is disabled. Existing users can still log in.</p>
{/if}
{/if}
{#if mode === 'login' || policy === true}
<form onsubmit={(event) => { event.preventDefault(); void submit(); }}>
<label>Account ID (handle)
<input bind:value={handle} required maxlength="128" autocomplete="username webauthn" autocapitalize="off" spellcheck="false" disabled={auth.busy} aria-describedby="id-help" />
</label>
<p id="id-help" class="muted">{mode === 'login' ? 'Use your account ID, not your display name.' : 'Your unique sign-in ID. This is separate from your display name.'}</p>
{#if mode === 'register'}
<label>Display name
<input bind:value={displayName} required maxlength="128" autocomplete="name" disabled={auth.busy} />
</label>
{/if}
<button class="primary" type="submit" disabled={auth.busy || !handle.trim() || (mode === 'register' && !displayName.trim())}>{mode === 'login' ? 'Log in with passkey' : 'Register new account'}</button>
</form>
{/if}
{/if}
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
<nav aria-label="Authentication">
{#if mode === 'login'}
<a href={authEntryUrl('/register', destination)}>Create a new account</a>
{:else}
<a href={authEntryUrl('/login', destination)}>Log in with an existing account</a>
{/if}
<a href="/activate">Activate an account with a code</a>
<a href="/">Dashboard / Workspaces</a>
</nav>
</div>
+43 -10
View File
@@ -20,6 +20,12 @@ import {
type WhoamiResponse,
} from "#lib/workspace/auth/model.ts";
export class AuthRequestError extends Error {
constructor(readonly status: number) {
super(`Auth request failed (${status}).`);
}
}
const MAX_AUTH_RESPONSE_BYTES = 256 * 1024;
export async function readBoundedAuthResponseJson(
@@ -80,6 +86,9 @@ export async function readBoundedAuthResponseJson(
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(path, {
credentials: "same-origin",
mode: "same-origin",
redirect: "error",
cache: "no-store",
...init,
headers: {
"content-type": "application/json",
@@ -88,20 +97,23 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Auth request failed (${response.status}).`);
throw new AuthRequestError(response.status);
}
return await readBoundedAuthResponseJson(response);
}
export async function loadWhoami(): Promise<WhoamiResponse> {
return parseWhoamiResponse(await requestJson("/api/auth/whoami"));
export async function loadWhoami(
signal?: AbortSignal,
): Promise<WhoamiResponse> {
return parseWhoamiResponse(await requestJson("/api/auth/whoami", { signal }));
}
export async function logout(): Promise<void> {
export async function logout(signal?: AbortSignal): Promise<void> {
parseLogoutResponse(
await requestJson("/api/auth/logout", {
method: "POST",
body: "{}",
signal,
}),
);
}
@@ -109,20 +121,21 @@ export async function logout(): Promise<void> {
export async function registerPasskey(
handle: string,
displayName?: string,
signal?: AbortSignal,
): Promise<AuthUser> {
const optionsRequest: PasskeyRegistrationOptionsRequest = {
handle,
display_name: displayName ?? null,
browser_origin: window.location.origin,
};
return await createPasskey("registration", optionsRequest);
return await createPasskey("registration", optionsRequest, signal);
}
export async function addPasskey(): Promise<AuthUser> {
export async function addPasskey(signal?: AbortSignal): Promise<AuthUser> {
const optionsRequest: PasskeyAdditionOptionsRequest = {
browser_origin: window.location.origin,
};
return await createPasskey("addition", optionsRequest);
return await createPasskey("addition", optionsRequest, signal);
}
async function createPasskey(
@@ -130,10 +143,12 @@ async function createPasskey(
optionsRequest:
| PasskeyRegistrationOptionsRequest
| PasskeyAdditionOptionsRequest,
signal?: AbortSignal,
): Promise<AuthUser> {
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
method: "POST",
body: JSON.stringify(optionsRequest),
signal,
});
const optionsRecord = typeof options === "object" && options !== null
? options as Record<string, unknown>
@@ -144,11 +159,16 @@ async function createPasskey(
"Invalid auth payload: registration_options.challenge_id is required.",
);
}
signal?.throwIfAborted();
const credential = await navigator.credentials.create({
signal,
publicKey: prepareRegistrationOptions(options),
});
if (!credential) throw new Error("Passkey registration was cancelled");
if (!credential) {
throw new DOMException("Passkey cancelled", "NotAllowedError");
}
signal?.throwIfAborted();
const completeRequest: PasskeyRegistrationCompleteRequest = {
challenge_id: challengeId,
credential: registrationCredentialToJson(credential),
@@ -157,12 +177,16 @@ async function createPasskey(
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
method: "POST",
body: JSON.stringify(completeRequest),
signal,
}),
);
return result.user;
}
export async function loginWithPasskey(handle: string): Promise<AuthUser> {
export async function loginWithPasskey(
handle: string,
signal?: AbortSignal,
): Promise<AuthUser> {
const loginHandle = handle.trim();
if (!loginHandle) {
throw new Error("User handle is required for passkey login.");
@@ -174,6 +198,7 @@ export async function loginWithPasskey(handle: string): Promise<AuthUser> {
const options = await requestJson("/api/auth/passkeys/login/options", {
method: "POST",
body: JSON.stringify(optionsRequest),
signal,
});
const optionsRecord = typeof options === "object" && options !== null
? options as Record<string, unknown>
@@ -184,11 +209,16 @@ export async function loginWithPasskey(handle: string): Promise<AuthUser> {
"Invalid auth payload: login_options.challenge_id is required.",
);
}
signal?.throwIfAborted();
const credential = await navigator.credentials.get({
signal,
publicKey: prepareLoginOptions(options),
});
if (!credential) throw new Error("Passkey login was cancelled");
if (!credential) {
throw new DOMException("Passkey cancelled", "NotAllowedError");
}
signal?.throwIfAborted();
const completeRequest: PasskeyLoginCompleteRequest = {
challenge_id: challengeId,
credential: authenticationCredentialToJson(credential),
@@ -197,6 +227,7 @@ export async function loginWithPasskey(handle: string): Promise<AuthUser> {
await requestJson("/api/auth/passkeys/login/complete", {
method: "POST",
body: JSON.stringify(completeRequest),
signal,
}),
);
return result.user;
@@ -204,12 +235,14 @@ export async function loginWithPasskey(handle: string): Promise<AuthUser> {
export async function approveDeviceLogin(
userCode: string,
signal?: AbortSignal,
): Promise<DeviceApprovalResponse> {
const request: DeviceLoginApproveRequest = { user_code: userCode };
return parseDeviceApprovalResponse(
await requestJson("/api/auth/device-login/approve", {
method: "POST",
body: JSON.stringify(request),
signal,
}),
);
}
@@ -0,0 +1,21 @@
.auth-page { display: grid; align-content: start; gap: var(--space-5); min-width: 0; }
.auth-page h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); }
.auth-page h2 { margin: 0; font-size: var(--font-size-body); }
.auth-page p { margin: 0; }
.auth-page form, .auth-page section, .auth-page nav { display: grid; gap: var(--space-4); min-width: 0; }
.auth-page label { display: grid; gap: var(--space-2); font-weight: 700; }
.auth-page input { width: 100%; min-width: 0; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--bg); color: var(--text-strong); font: inherit; }
.auth-page 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; }
.auth-page button.primary { background: var(--text-strong); color: var(--bg); }
.auth-page button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); }
.auth-page button:disabled { opacity: .55; cursor: not-allowed; }
.auth-page a { color: var(--text-strong); text-decoration: underline; text-underline-offset: .15em; overflow-wrap: anywhere; }
.auth-page a:hover, .auth-page a:active { color: var(--text); }
.auth-page :is(a, button, input):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.auth-page .muted { color: var(--text-muted); }
.auth-page .error { color: var(--danger); }
.auth-page .actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.auth-page dl { display: grid; gap: var(--space-4); margin: 0; }
.auth-page dl div { display: grid; gap: var(--space-1); min-width: 0; }
.auth-page dt { color: var(--text-muted); }
.auth-page dd { margin: 0; overflow-wrap: anywhere; }
@@ -0,0 +1,104 @@
import { AuthRequestError, loadWhoami } from "./api.ts";
import { notifySessionChanged } from "./hosting.ts";
import type { RequestActor } from "./model.ts";
export type BrowserSession =
| { kind: "loading" }
| { kind: "error" }
| { kind: "ready"; actor: RequestActor | null };
export function authErrorMessage(error: unknown): string {
if (error instanceof AuthRequestError) {
if (error.status === 401) {
return "Your session or authentication challenge expired. Log in again or restart the Passkey operation.";
}
if (error.status === 400 || error.status === 410) {
return "The request or authentication challenge is invalid or expired. Restart the operation.";
}
return error.message;
}
if (error instanceof DOMException && error.name === "NotAllowedError") {
return "Passkey operation was cancelled or timed out. You can try again.";
}
if (error instanceof DOMException && error.name === "AbortError") {
return "Operation stopped. Completion is not confirmed; check your session before retrying.";
}
// Browser/transport例外の本文を表示せず、credentialや外部応答を診断へ複写しない。
return "Authentication could not be confirmed. Check your session or try Passkey login before retrying registration.";
}
export class BrowserAuth {
session = $state<BrowserSession>({ kind: "loading" });
busy = $state(false);
error = $state("");
status = $state("");
private request = 0;
private disposed = false;
private controller: AbortController | null = null;
get actor(): RequestActor | null {
return this.session.kind === "ready" ? this.session.actor : null;
}
async refresh(): Promise<void> {
if (this.busy || this.disposed) return;
await this.readSession();
}
private async readSession(): Promise<void> {
const current = ++this.request;
this.session = { kind: "loading" };
try {
const result = await loadWhoami();
if (this.disposed || current !== this.request) return;
this.session = { kind: "ready", actor: result.actor };
} catch {
if (this.disposed || current !== this.request) return;
this.session = { kind: "error" };
}
}
async run(
pending: string,
success: string,
operation: (signal: AbortSignal) => Promise<unknown>,
): Promise<boolean> {
if (this.busy || this.disposed || this.session.kind !== "ready") {
return false;
}
this.busy = true;
++this.request;
const controller = new AbortController();
this.controller = controller;
this.error = "";
this.status = pending;
try {
await operation(controller.signal);
if (this.disposed) return false;
notifySessionChanged();
await this.readSession();
if (this.disposed) return false;
const confirmed = this.session.kind === "ready";
this.status = confirmed ? success : "";
return confirmed;
} catch (error) {
if (this.disposed) return false;
this.error = authErrorMessage(error);
this.status = "";
// 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。
await this.readSession();
return false;
} finally {
if (!this.disposed) {
this.busy = false;
this.controller = null;
}
}
}
dispose(): void {
this.disposed = true;
++this.request;
this.controller?.abort();
}
}
@@ -0,0 +1,84 @@
declare const Deno: { test(name: string, run: () => void): void };
function assertEquals(
actual: string,
expected: string,
context?: string,
): void {
if (actual !== expected) {
throw new Error(
`${context ?? "return destination"}: expected ${expected}, got ${actual}`,
);
}
}
import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts";
Deno.test("return destination permits known app screens without forwarding arbitrary query or fragment", () => {
for (
const [input, expected] of [
["/", "/"],
["/account", "/account"],
["/account?returnTo=//evil.invalid#secret", "/account"],
[
"/w/workspace-id/tickets/T-123?filter=all#entry",
"/w/workspace-id/tickets/T-123",
],
[
"/login/device?user_code=ABCD-EFGH",
"/login/device?user_code=ABCD-EFGH",
],
[
"/login/device?code=abcd-1234&returnTo=//evil.invalid",
"/login/device?user_code=abcd-1234",
],
["/login/device?user_code=%252f%252fevil.invalid", "/login/device"],
]
) assertEquals(safeReturnTo(input), expected, input);
});
Deno.test("return destination rejects external, ambiguous encoding and authentication loops", () => {
for (
const input of [
null,
"",
"https://evil.invalid",
"//evil.invalid",
"///evil.invalid",
"javascript:alert(1)",
"account",
"/\\evil.invalid",
"/%2f%2fevil.invalid",
"/%252f%252fevil.invalid",
"/%61ccount",
"/login",
"/register",
"/activate",
"/login/",
"/login?returnTo=/account",
"/login/device/",
"/login/../account",
"/w/a/../../login",
"/w/a//evil",
"/w/a/./b",
"/account\n",
"/account\t",
"/account\u0000",
"/account%00",
"/api/auth/logout",
"/hosting/setup",
"/not-an-app-route",
"/" + "a".repeat(2049),
]
) assertEquals(safeReturnTo(input), "/", String(input));
});
Deno.test("login links encode one validated destination and discard nested auth loops", () => {
assertEquals(
loginUrl("/login/device?user_code=ABCD-EFGH"),
"/login?returnTo=%2Flogin%2Fdevice%3Fuser_code%3DABCD-EFGH",
);
assertEquals(
authEntryUrl("/register", "/account"),
"/register?returnTo=%2Faccount",
);
assertEquals(loginUrl("/login?returnTo=/login"), "/login?returnTo=%2F");
});
@@ -0,0 +1,59 @@
// T760が公開トップ/Workspace選択を整理するまで、現行の一覧をdashboardへの入口とする。
// 戻り先は既知のアプリ画面だけ。認証経路への再帰やdecode段階の差による別originを作らない。
export const AUTHENTICATED_HOME = "/";
export function safeReturnTo(value: string | null): string {
if (
!value || value.length > 2048 || !value.startsWith("/") ||
/[\\\s\u0000-\u001f\u007f]/u.test(value) || value.startsWith("//")
) {
return AUTHENTICATED_HOME;
}
const rawPath = value.split(/[?#]/, 1)[0];
if (
rawPath.includes("%") || rawPath.includes("//") ||
rawPath.split("/").some((part) => part === "." || part === "..")
) {
return AUTHENTICATED_HOME;
}
let url: URL;
try {
url = new URL(value, "https://app.invalid");
} catch {
return AUTHENTICATED_HOME;
}
if (url.origin !== "https://app.invalid") return AUTHENTICATED_HOME;
if (url.pathname === "/login/device") {
const code = url.searchParams.get("user_code") ??
url.searchParams.get("code");
if (code && /^[a-zA-Z0-9-]{1,64}$/.test(code)) {
return `/login/device?user_code=${encodeURIComponent(code)}`;
}
return "/login/device";
}
if (
url.pathname === "/" || url.pathname === "/account" ||
/^\/w\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_/-]*)?$/.test(url.pathname)
) {
return url.pathname;
}
return AUTHENTICATED_HOME;
}
export function loginUrl(returnTo: string): string {
return `/login?${new URLSearchParams({ returnTo: safeReturnTo(returnTo) })}`;
}
export function authEntryUrl(
route: "/login" | "/register",
returnTo: string,
): string {
return `${route}?${new URLSearchParams({
returnTo: safeReturnTo(returnTo),
})}`;
}
export function isAuthPage(path: string): boolean {
return ["/login", "/register", "/account", "/activate", "/login/device"]
.includes(path);
}
@@ -0,0 +1,467 @@
// @vitest-environment happy-dom
import { afterEach, expect, test, vi } from "vitest";
import { BrowserAuth } from "./browser-session.svelte.ts";
import { addPasskey, loginWithPasskey, logout } from "./api.ts";
import { SESSION_CHANGED_EVENT } from "./hosting.ts";
import {
authUser,
credentialFixture,
loginOptions,
registrationOptions,
} from "./api.test-fixtures.ts";
const sessions: BrowserAuth[] = [];
afterEach(() => {
for (const auth of sessions.splice(0)) auth.dispose();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function gate<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function session() {
const auth = new BrowserAuth();
sessions.push(auth);
return auth;
}
function whoami(authenticated: boolean) {
return Response.json({
actor: authenticated
? { ...authUser, auth_method: "browser_session" }
: null,
});
}
// BrowserAuth→本番HTTP/WebAuthn adapterの状態遷移を検証する。fetch/credentialだけを代替し、
// 遅延応答は明示的なgateで順序を決める。実Backendの認証・永続化を保証する試験ではない。
function boundary(authenticated = false) {
const state = { authenticated, completeStatus: 200 };
const create = vi.fn<CredentialsContainer["create"]>()
.mockResolvedValue(credentialFixture());
const get = vi.fn<CredentialsContainer["get"]>()
.mockResolvedValue(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 whoami(state.authenticated);
if (path === "/api/auth/logout") {
state.authenticated = false;
return Response.json({ status: "logged_out" });
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/addition/options") {
return Response.json(registrationOptions);
}
if (
path === "/api/auth/passkeys/login/complete" ||
path === "/api/auth/passkeys/addition/complete"
) {
if (state.completeStatus !== 200) {
if (state.completeStatus === 401) state.authenticated = false;
return new Response(null, { status: state.completeStatus });
}
state.authenticated = true;
return Response.json({ user: authUser });
}
throw new Error(`Unexpected API: ${path}`);
});
vi.stubGlobal("fetch", fetcher);
return { fetcher, create, get, state };
}
function paths(fetcher: ReturnType<typeof boundary>["fetcher"]) {
return fetcher.mock.calls.map(([path]) => String(path));
}
test("loading and failed whoami are not anonymous readiness and cannot start authentication", async () => {
const { fetcher } = boundary();
const response = gate<Response>();
fetcher.mockReturnValueOnce(response.promise);
const auth = session();
const operation = vi.fn(async () => {});
expect(auth.session.kind).toBe("loading");
const refresh = auth.refresh();
expect(auth.actor).toBeNull();
expect(await auth.run("pending", "done", operation)).toBe(false);
response.resolve(new Response(null, { status: 503 }));
await refresh;
expect(auth.session).toEqual({ kind: "error" });
expect(await auth.run("pending", "done", operation)).toBe(false);
expect(operation).not.toHaveBeenCalled();
await auth.refresh();
expect(auth.session).toEqual({ kind: "ready", actor: null });
});
for (
const [olderStatus, latestStatus, latestAuthenticated] of [
[200, 200, false],
[200, 503, false],
[503, 200, true],
] as const
) {
test(`latest whoami (${latestStatus}, actor=${latestAuthenticated}) wins over a delayed ${olderStatus} response`, async () => {
const { fetcher } = boundary();
const older = gate<Response>();
const latest = gate<Response>();
fetcher.mockReturnValueOnce(older.promise).mockReturnValueOnce(
latest.promise,
);
const auth = session();
const first = auth.refresh();
const second = auth.refresh();
latest.resolve(
latestStatus === 200
? whoami(latestAuthenticated)
: new Response(null, { status: latestStatus }),
);
await second;
expect(auth.session.kind).toBe(latestStatus === 200 ? "ready" : "error");
expect(auth.actor?.handle ?? null).toBe(
latestAuthenticated ? "alice" : null,
);
const accepted = auth.session;
older.resolve(
olderStatus === 200
? whoami(true)
: new Response(null, { status: olderStatus }),
);
await first;
expect(auth.session).toBe(accepted);
});
}
test("a delayed pre-logout whoami cannot resurrect the previous actor", async () => {
const { fetcher } = boundary(true);
const auth = session();
await auth.refresh();
const oldResponse = gate<Response>();
fetcher.mockReturnValueOnce(oldResponse.promise);
const oldRefresh = auth.refresh();
await auth.refresh();
expect(auth.actor?.handle).toBe("alice");
expect(await auth.run("Logging out", "Logged out", logout)).toBe(true);
expect(auth.session).toEqual({ kind: "ready", actor: null });
oldResponse.resolve(whoami(true));
await oldRefresh;
expect(auth.session).toEqual({ kind: "ready", actor: null });
expect(auth.status).toBe("Logged out");
});
test("busy login rejects duplicate operations and refresh until its post-completion whoami settles", async () => {
const { fetcher } = boundary();
const auth = session();
await auth.refresh();
const options = gate<Response>();
const confirming = gate<Response>();
const confirmingStarted = gate<void>();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => {
if (String(input) === "/api/auth/whoami") {
confirmingStarted.resolve();
return confirming.promise;
}
return request(input, init);
});
fetcher.mockReturnValueOnce(options.promise);
const run = auth.run(
"Waiting",
"Logged in",
(signal) => loginWithPasskey("alice", signal),
);
expect(auth.busy).toBe(true);
expect(auth.status).toBe("Waiting");
const duplicate = vi.fn(async () => {});
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
await auth.refresh();
expect(duplicate).not.toHaveBeenCalled();
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
]);
options.resolve(Response.json(loginOptions));
await confirmingStarted.promise;
expect(auth.busy).toBe(true);
expect(auth.session.kind).toBe("loading");
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
await auth.refresh();
expect(paths(fetcher).filter((path) => path === "/api/auth/whoami"))
.toHaveLength(2);
confirming.resolve(whoami(true));
expect(await run).toBe(true);
expect(auth.busy).toBe(false);
expect(auth.actor?.handle).toBe("alice");
expect(auth.status).toBe("Logged in");
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/passkeys/login/complete",
"/api/auth/whoami",
]);
});
for (const status of [400, 401, 410]) {
test(`addition completion ${status} returns failure, clears success and rechecks the session`, async () => {
const { fetcher, state } = boundary(true);
const auth = session();
await auth.refresh();
state.completeStatus = status;
const result = await auth.run("Waiting", "Added", addPasskey);
expect(result).toBe(false);
expect(auth.busy).toBe(false);
expect(auth.status).toBe("");
expect(auth.error).toMatch(
status === 401
? /Your session or authentication challenge expired/
: /The request or authentication challenge is invalid or expired/,
);
expect(auth.session.kind).toBe("ready");
expect(auth.actor?.handle ?? null).toBe(status === 401 ? null : "alice");
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
"/api/auth/whoami",
]);
});
}
for (const name of ["NotAllowedError", "AbortError", "Error"] as const) {
test(`credential ${name} is sanitized, never completed, and leaves explicit retry possible`, async () => {
const { fetcher, get } = boundary();
const auth = session();
await auth.refresh();
get.mockRejectedValueOnce(
name === "Error"
? new Error("secret credential detail")
: new DOMException("secret credential detail", name),
);
expect(
await auth.run(
"Waiting",
"Logged in",
(signal) => loginWithPasskey("alice", signal),
),
).toBe(false);
expect(auth.error).toMatch(
name === "NotAllowedError"
? /cancelled or timed out/
: name === "AbortError"
? /Completion is not confirmed/
: /Authentication could not be confirmed/,
);
expect(auth.error).not.toContain("secret credential detail");
expect(auth.status).toBe("");
expect(auth.busy).toBe(false);
expect(auth.session).toEqual({ kind: "ready", actor: null });
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami",
]);
expect(
await auth.run(
"Waiting",
"Logged in",
(signal) => loginWithPasskey("alice", signal),
),
).toBe(true);
expect(auth.error).toBe("");
expect(auth.actor?.handle).toBe("alice");
});
}
test("completion does not confirm authentication when the subsequent whoami fails", async () => {
const { fetcher } = boundary();
const auth = session();
await auth.refresh();
fetcher.mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") return new Response(null, { status: 503 });
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/login/complete") {
return Response.json({ user: authUser });
}
throw new Error(`Unexpected API: ${path}`);
});
expect(
await auth.run(
"Waiting",
"Logged in",
(signal) => loginWithPasskey("alice", signal),
),
).toBe(false);
expect(auth.session).toEqual({ kind: "error" });
expect(auth.status).toBe("");
expect(auth.actor).toBeNull();
expect(auth.busy).toBe(false);
});
for (const rejected of [false, true]) {
test(`dispose ignores an outstanding whoami ${rejected ? "error" : "actor"} response and rejects future work`, async () => {
const { fetcher } = boundary();
const response = gate<Response>();
fetcher.mockReturnValueOnce(response.promise);
const auth = session();
const refresh = auth.refresh();
auth.dispose();
response.resolve(
rejected ? new Response(null, { status: 503 }) : whoami(true),
);
await refresh;
expect(auth.session).toEqual({ kind: "loading" });
expect(auth.actor).toBeNull();
await auth.refresh();
const operation = vi.fn(async () => {});
expect(await auth.run("Waiting", "Logged in", operation)).toBe(false);
expect(operation).not.toHaveBeenCalled();
expect(fetcher).toHaveBeenCalledOnce();
});
}
test("dispose aborts WebAuthn and ignores a late credential without completion, session notification or refresh", async () => {
const { fetcher, get } = boundary();
const auth = session();
await auth.refresh();
const credential = gate<Credential | null>();
const started = gate<AbortSignal>();
get.mockImplementationOnce((options) => {
started.resolve(options!.signal!);
return credential.promise;
});
const dispatch = vi.spyOn(window, "dispatchEvent");
const run = auth.run(
"Waiting",
"Logged in",
(signal) => loginWithPasskey("alice", signal),
);
const signal = await started.promise;
expect(signal.aborted).toBe(false);
auth.dispose();
expect(signal.aborted).toBe(true);
credential.resolve(credentialFixture(true));
expect(await run).toBe(false);
expect(auth.session).toEqual({ kind: "ready", actor: null });
expect(auth.status).toBe("Waiting");
expect(auth.error).toBe("");
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
]);
expect(
dispatch.mock.calls.filter(([event]) =>
event.type === SESSION_CHANGED_EVENT
),
).toEqual([]);
});
test("dispose ignores a completion response even when the HTTP boundary does not honor abort", async () => {
const { fetcher } = boundary();
const auth = session();
await auth.refresh();
const response = gate<Response>();
const started = gate<AbortSignal>();
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/login/complete") {
started.resolve(init!.signal!);
return await response.promise;
}
throw new Error(`Unexpected API: ${path}`);
});
const dispatch = vi.spyOn(window, "dispatchEvent");
const run = auth.run(
"Waiting",
"Logged in",
(signal) => loginWithPasskey("alice", signal),
);
const signal = await started.promise;
auth.dispose();
expect(signal.aborted).toBe(true);
response.resolve(Response.json({ user: authUser }));
expect(await run).toBe(false);
expect(auth.actor).toBeNull();
expect(auth.status).toBe("Waiting");
expect(auth.error).toBe("");
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/passkeys/login/complete",
]);
expect(
dispatch.mock.calls.filter(([event]) =>
event.type === SESSION_CHANGED_EVENT
),
).toEqual([]);
});
test("dispose during the post-operation whoami prevents its late actor from becoming readiness", async () => {
const { fetcher } = boundary();
const auth = session();
await auth.refresh();
const response = gate<Response>();
const started = gate<void>();
fetcher.mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/login/complete") {
return Response.json({ user: authUser });
}
if (path === "/api/auth/whoami") {
started.resolve();
return await response.promise;
}
throw new Error(`Unexpected API: ${path}`);
});
const run = auth.run(
"Waiting",
"Logged in",
(signal) => loginWithPasskey("alice", signal),
);
await started.promise;
expect(auth.session.kind).toBe("loading");
expect(auth.busy).toBe(true);
auth.dispose();
response.resolve(whoami(true));
expect(await run).toBe(false);
expect(auth.session.kind).toBe("loading");
expect(auth.actor).toBeNull();
});
for (const purpose of ["login", "addition"] as const) {
test(`${purpose} null credential reports cancellation and never sends completion`, async () => {
const { fetcher, get, create } = boundary(purpose === "addition");
const auth = session();
await auth.refresh();
(purpose === "login" ? get : create).mockResolvedValueOnce(null);
expect(
await auth.run(
"Waiting",
"Confirmed",
(signal) =>
purpose === "login"
? loginWithPasskey("alice", signal)
: addPasskey(signal),
),
).toBe(false);
expect(auth.error).toMatch(/cancelled or timed out/);
expect(paths(fetcher).some((path) => path.endsWith("/complete"))).toBe(
false,
);
});
}
@@ -72,8 +72,8 @@ Deno.test("workspace feature css is owned outside app css", async () => {
workspaceLayout.includes("#lib/workspace/styles/workspace-pages.css") &&
workspaceLayout.includes("#lib/workspace/styles/workers.css") &&
settingsLayout.includes("#lib/workspace/styles/settings.css") &&
accountPage.includes("#lib/workspace/styles/settings.css") &&
deviceLoginPage.includes("#lib/workspace/styles/settings.css"),
accountPage.includes("#lib/workspace/auth/auth-page.css") &&
deviceLoginPage.includes("#lib/workspace/auth/auth-page.css"),
"feature-owned CSS should be imported by the layouts/pages that need it",
);
});
@@ -1277,13 +1277,7 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime
);
});
Deno.test("Account UI owns browser passkey session state without workspace authorization", async () => {
const accountPage = await Deno.readTextFile(
new URL("./../../../routes/account/+page.svelte", import.meta.url),
);
const devicePage = await Deno.readTextFile(
new URL("./../../../routes/login/device/+page.svelte", import.meta.url),
);
Deno.test("Root chrome owns global navigation and scoped override slots", async () => {
const rootLayout = await Deno.readTextFile(
new URL("./../../../routes/+layout.svelte", import.meta.url),
);
@@ -1321,21 +1315,6 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
);
assert(
accountPage.includes("registerPasskey") &&
accountPage.includes("loginWithPasskey") &&
accountPage.includes("logout") &&
accountPage.includes("loadWhoami") &&
accountPage.includes("Current user"),
"Account page should expose registration, login, logout, and current-session inspection",
);
assert(
devicePage.includes("approveDeviceLogin") &&
devicePage.includes("loginWithPasskey") &&
devicePage.includes("user_code") &&
devicePage.includes("Approve device login"),
"Device login page should approve CLI login without DevTools console",
);
assert(
rootLayout.includes("SIDEBAR_CONTEXT") &&
rootLayout.includes("GlobalSidebar") &&
+3 -2
View File
@@ -26,8 +26,9 @@
import { observeDeferred } from '#lib/workspace/navigation/observe-deferred.svelte.ts';
let { children, data }: LayoutProps = $props();
// Hostingと初回登録はWorkspaceに依存しない。login全体の分離はここで扱わない。
const standalone = $derived(page.url.pathname === '/activate' || page.url.pathname.startsWith('/hosting/'));
import { isAuthPage } from '#lib/workspace/auth/routes.ts';
// 認証画面はWorkspaceの存在/取得/Sidebarに依存しない。
const standalone = $derived(isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/'));
const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog');
let sidebar = $state<SidebarSnippet | null>(null);
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
+3 -2
View File
@@ -1,3 +1,4 @@
import { isAuthPage } from "#lib/workspace/auth/routes.ts";
import { listWorkspaces } from "#lib/workspace/api/workspace-catalog.ts";
import { settle } from "#lib/workspace/navigation/deferred.ts";
import type { Load } from "@sveltejs/kit";
@@ -6,8 +7,8 @@ export const ssr = false;
export const load = ({ fetch, url }: Parameters<Load>[0]) => ({
deferredCatalog: settle((async () => {
// 初回有効化とhosting管理はWorkspace未作成でも利用する。
if (url.pathname === "/activate" || url.pathname.startsWith("/hosting/")) {
// 認証/hostingの判断はWorkspace catalogを必要としない。
if (isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/")) {
return { accessibleWorkspaces: [], workspaceCatalogError: null };
}
try {
+44 -201
View File
@@ -1,213 +1,56 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
addPasskey,
loadWhoami,
loginWithPasskey,
logout,
registerPasskey,
} 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';
import { goto } from '$app/navigation';
import { addPasskey, logout } from '#lib/workspace/auth/api.ts';
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
import { loginUrl } from '#lib/workspace/auth/routes.ts';
import '#lib/workspace/auth/auth-page.css';
let actor = $state<RequestActor | null>(null);
let handle = $state('local');
let displayName = $state('Local User');
let loading = $state(true);
let sessionReady = $state(false);
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;
const auth = new BrowserAuth();
async function refresh() {
await auth.refresh();
if (auth.session.kind === 'ready' && !auth.actor) {
await goto(loginUrl('/account'), { replaceState: true });
}
}
async function refreshWhoami() {
loading = true;
sessionReady = false;
error = '';
try {
const whoami = await loadWhoami();
actor = whoami.actor;
sessionReady = true;
notifySessionChanged();
if (actor?.handle) {
handle = actor.handle;
displayName = actor.display_name;
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
async function add() {
if (!auth.actor) return;
await auth.run('Waiting for passkey addition…', 'Passkey added to your account.', addPasskey);
}
async function register() {
if (busy || !sessionReady || (!actor && policy !== true)) return;
const adding = actor !== null;
busy = true;
status = adding ? 'Waiting for passkey addition…' : 'Waiting for new account registration…';
error = '';
try {
if (adding) {
await addPasskey();
} else {
await registerPasskey(handle.trim(), displayName.trim() || handle.trim());
}
status = adding ? 'Passkey added to your account.' : 'New account registered.';
await refreshWhoami();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
status = '';
if (!adding) void refreshPolicy();
} finally {
busy = false;
}
}
async function login() {
busy = true;
status = 'Waiting for passkey login…';
error = '';
try {
await loginWithPasskey(handle.trim());
status = 'Logged in.';
await refreshWhoami();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
status = '';
} finally {
busy = false;
}
}
async function signOut() {
busy = true;
error = '';
try {
await logout();
actor = null;
notifySessionChanged();
void refreshPolicy();
status = 'Logged out.';
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
busy = false;
}
if (!auth.actor) return;
const confirmed = await auth.run('Logging out…', 'Logged out.', logout);
if (confirmed && !auth.actor) await goto(loginUrl('/account'), { replaceState: true });
}
onMount(() => {
void refreshWhoami();
void refreshPolicy();
window.addEventListener('focus', refreshPolicy);
return () => {
++policyRequest;
window.removeEventListener('focus', refreshPolicy);
};
});
onMount(() => { void refresh(); return () => auth.dispose(); });
</script>
<svelte:head>
<title>Account · Yoi Workspace</title>
</svelte:head>
<main class="settings-page">
<header class="settings-header">
<p class="eyebrow">Account</p>
<h1>Account</h1>
<p>Register a passkey, sign in, sign out, and inspect the current browser session.</p>
</header>
<section class="settings-panel account-panel">
<div>
<h2>Current user</h2>
{#if loading}
<p class="muted">Loading session…</p>
{:else if actor}
<dl class="account-details">
<div>
<dt>Handle</dt>
<dd>{actor.handle}</dd>
</div>
<div>
<dt>Display name</dt>
<dd>{actor.display_name}</dd>
</div>
<div>
<dt>User ID</dt>
<dd><code>{actor.user_id}</code></dd>
</div>
<div>
<dt>Account ID</dt>
<dd><code>{actor.account_id}</code></dd>
</div>
<div>
<dt>Auth kind</dt>
<dd>{actor.auth_method}</dd>
</div>
</dl>
<div class="settings-action-row">
<button type="button" disabled={busy} onclick={signOut}>Log out</button>
<button class="secondary-button" type="button" disabled={busy} onclick={refreshWhoami}>Refresh</button>
</div>
{:else}
<p class="muted">No browser session is active.</p>
{/if}
<svelte:head><title>Account · Yoi</title></svelte:head>
<div class="auth-page" data-sveltekit-preload-data="off">
<h1>Account</h1>
{#if auth.session.kind === 'loading'}
<p class="muted" role="status">Loading session…</p>
{:else if auth.session.kind === 'error'}
<p role="alert" class="error">Your session could not be loaded. Login status is unknown.</p>
<button type="button" disabled={auth.busy} onclick={refresh}>Retry session</button>
{:else if auth.actor}
<dl>
<div><dt>Account ID (handle)</dt><dd>{auth.actor.handle}</dd></div>
<div><dt>Display name</dt><dd>{auth.actor.display_name}</dd></div>
</dl>
<section aria-label="Your Passkeys">
<h2>Passkeys</h2>
<button type="button" disabled={auth.busy} onclick={add}>Add passkey to {auth.actor.handle}</button>
</section>
<div class="actions">
<button type="button" disabled={auth.busy} onclick={signOut}>Log out</button>
<button type="button" disabled={auth.busy} onclick={refresh}>Refresh session</button>
</div>
</section>
<section class="settings-panel">
<h2>Passkey</h2>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); }}>
<label>
{actor ? 'Current user handle' : 'User handle (new registration or login)'}
<input bind:value={handle} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="username webauthn" placeholder="local" />
</label>
<label>
Display name (new account only)
<input bind:value={displayName} disabled={busy || loading || !sessionReady || actor !== null || policy !== true} autocomplete="name" placeholder="Local User" />
</label>
<div class="settings-action-row">
{#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}
<p class="status-message">{status}</p>
{:else}
<p>No browser session is active.</p>
<a href={loginUrl('/account')}>Log in to your account</a>
{/if}
{#if error}
<p class="error-message">{error}</p>
{/if}
</main>
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
<a href="/">Dashboard / Workspaces</a>
</div>
@@ -7,7 +7,12 @@ import {
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import { tick } from "svelte";
import { goto } from "$app/navigation";
import Account from "./+page.svelte";
import Login from "../login/+page.svelte";
import Register from "../register/+page.svelte";
import Device from "../login/device/+page.svelte";
import {
authUser,
credentialFixture,
@@ -15,85 +20,248 @@ import {
registrationOptions,
} from "#lib/workspace/auth/api.test-fixtures.ts";
const page = vi.hoisted(() => ({ url: new URL("https://example.test/login") }));
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
vi.mock("$app/state", () => ({ page }));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
page.url = new URL("https://example.test/login");
});
// UIから本番HTTP adapterまでを接続し、fetch/ブラウザーcredential境界だけを代替する。
function boundary(
authenticated: boolean,
function gate<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
type Policy = boolean | "error" | "loading";
// 各routeのUI→本番HTTP/WebAuthn変換を接続し、fetchとcredential境界だけを代替する。
// Backendの署名検証・認可・永続化はこのsuiteの保証対象ではない。
function boundary({
authenticated = false,
completeStatus = 200,
policy: boolean | "error" | "loading" = true,
) {
const create = vi.fn(async () => credentialFixture());
const get = vi.fn(async () => credentialFixture(true));
policy = true as Policy,
} = {}) {
const state = { authenticated, completeStatus, policy, whoamiStatus: 200 };
const policyResponse = gate<Response>();
const create = vi.fn<CredentialsContainer["create"]>()
.mockResolvedValue(credentialFixture());
const get = vi.fn<CredentialsContainer["get"]>()
.mockResolvedValue(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") {
if (state.whoamiStatus !== 200) {
return new Response(null, { status: state.whoamiStatus });
}
return Response.json({
actor: authenticated
actor: state.authenticated
? { ...authUser, auth_method: "browser_session" }
: 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 (state.policy === "loading") return await policyResponse.promise;
if (state.policy === "error") return new Response(null, { status: 503 });
return Response.json({ self_registration_enabled: state.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;
if (
/^\/api\/auth\/passkeys\/(registration|addition)\/options$/.test(path)
) {
return Response.json(registrationOptions);
}
if (
/^\/api\/auth\/passkeys\/(login|registration|addition)\/complete$/.test(
path,
)
) {
if (state.completeStatus !== 200) {
if (state.completeStatus === 403 && path.includes("/registration/")) {
state.policy = false;
}
return new Response(null, { status: completeStatus });
if (state.completeStatus === 401) state.authenticated = false;
return new Response(null, { status: state.completeStatus });
}
authenticated = true;
state.authenticated = true;
return Response.json({ user: authUser });
}
if (path === "/api/auth/logout") {
state.authenticated = false;
return Response.json({ status: "logged_out" });
}
if (path === "/api/auth/device-login/approve") {
if (state.completeStatus !== 200) {
if (state.completeStatus === 401) state.authenticated = false;
return new Response(null, { status: state.completeStatus });
}
return Response.json({ status: "approved", user: authUser });
}
throw new Error(`Unexpected API: ${path}`);
});
vi.stubGlobal("fetch", fetcher);
return { fetcher, create, get };
return { fetcher, create, get, state, policyResponse };
}
function posted(fetcher: ReturnType<typeof boundary>["fetcher"]) {
return fetcher.mock.calls.filter(([, init]) => init?.method === "POST").map((
[path, init],
) => ({
path: String(path),
body: JSON.parse(String(init!.body)),
credentials: init!.credentials,
}));
return fetcher.mock.calls.filter(([, init]) => init?.method === "POST").map(
([path, init]) => ({
path: String(path),
body: JSON.parse(String(init!.body)),
credentials: init!.credentials,
}),
);
}
test("anonymous existing Account page registers a new account via registration endpoints", async () => {
const { fetcher, create } = boundary(false);
render(Account);
const button = await screen.findByRole("button", {
name: "Register new account",
function apiPaths(fetcher: ReturnType<typeof boundary>["fetcher"]) {
return fetcher.mock.calls.map(([path]) => String(path));
}
async function enterIdentity(registering = false) {
await fireEvent.input(await screen.findByLabelText("Account ID (handle)"), {
target: { value: " alice " },
});
if (registering) {
await fireEvent.input(screen.getByLabelText("Display name"), {
target: { value: " Alice " },
});
}
}
async function submit(name: string) {
await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!);
}
test("login asks only for an account ID and links separate registration and code activation", async () => {
const { fetcher } = boundary({ policy: false });
page.url = new URL("https://example.test/login?returnTo=%2Faccount");
render(Login);
const handle = await screen.findByLabelText("Account ID (handle)");
expect(screen.queryByLabelText("Display name")).toBeNull();
expect(screen.queryByRole("button", { name: "Register new account" }))
.toBeNull();
expect(
screen.getByRole("link", { name: "Create a new account" }).getAttribute(
"href",
),
)
.toBe("/register?returnTo=%2Faccount");
expect(
screen.getByRole("link", { name: "Activate an account with a code" })
.getAttribute("href"),
)
.toBe("/activate");
await fireEvent.input(handle, { target: { value: " " } });
expect(
(screen.getByRole("button", {
name: "Log in with passkey",
}) as HTMLButtonElement).disabled,
)
.toBe(true);
await submit("Log in with passkey");
expect(posted(fetcher)).toEqual([]);
expect(apiPaths(fetcher)).not.toContain("/api/auth/registration-policy");
});
for (
const [returnTo, expected] of [
[null, "/"],
["/account", "/account"],
["/w/project/tickets?ignored=1#fragment", "/w/project/tickets"],
[
"/login/device?code=abcd-EFGH&untrusted=1",
"/login/device?user_code=abcd-EFGH",
],
["https://evil.test/", "/"],
["//evil.test/account", "/"],
["/\\evil.test/account", "/"],
["/%2f%2fevil.test", "/"],
["/login?returnTo=/login", "/"],
["/register", "/"],
["/activate", "/"],
] as const
) {
test(`login confirms whoami before navigating safely for returnTo=${returnTo}`, async () => {
const { fetcher, get, create } = boundary();
page.url = new URL("https://example.test/login");
if (returnTo !== null) page.url.searchParams.set("returnTo", returnTo);
render(Login);
await enterIdentity();
await submit("Log in with passkey");
await waitFor(() =>
expect(goto).toHaveBeenCalledWith(expected, { replaceState: true })
);
const requests = posted(fetcher);
expect(requests.map(({ path }) => path)).toEqual([
"/api/auth/passkeys/login/options",
"/api/auth/passkeys/login/complete",
]);
expect(requests[0].body).toEqual({
handle: "alice",
browser_origin: window.location.origin,
});
expect(requests[1].body.challenge_id).toBe(loginOptions.challenge_id);
expect(get).toHaveBeenCalledOnce();
expect(create).not.toHaveBeenCalled();
expect(apiPaths(fetcher).filter((path) => path === "/api/auth/whoami"))
.toHaveLength(2);
expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve");
});
}
test("successful login completion without confirmed whoami never navigates", async () => {
const { state, get } = boundary();
render(Login);
await enterIdentity();
get.mockImplementationOnce(async () => {
state.whoamiStatus = 503;
return credentialFixture(true);
});
await submit("Log in with passkey");
await screen.findByText(/Your session could not be loaded/);
expect(goto).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Log in with passkey" }))
.toBeNull();
});
test("register requires a separate ID and display name and uses only new-account endpoints", async () => {
const { fetcher, create, get } = boundary();
render(Register);
await screen.findByLabelText("Display name");
expect(
(screen.getByRole("button", {
name: "Register new account",
}) as HTMLButtonElement).disabled,
)
.toBe(true);
await fireEvent.input(screen.getByLabelText("Account ID (handle)"), {
target: { value: " alice " },
});
expect(
(screen.getByRole("button", {
name: "Register new account",
}) as HTMLButtonElement).disabled,
)
.toBe(true);
expect(screen.queryByRole("button", { name: "Log in with passkey" }))
.toBeNull();
await fireEvent.input(screen.getByLabelText("Display name"), {
target: { value: " Alice " },
});
await submit("Register new account");
await waitFor(() =>
expect((button as HTMLButtonElement).disabled).toBe(false)
expect(goto).toHaveBeenCalledWith("/", { replaceState: true })
);
await fireEvent.input(
screen.getByLabelText("User handle (new registration or login)"),
{ target: { value: " alice " } },
);
await fireEvent.input(
screen.getByLabelText("Display name (new account only)"),
{ target: { value: "Alice" } },
);
await fireEvent.click(button);
await screen.findByText("New account registered.");
const requests = posted(fetcher);
expect(requests.map((request) => request.path)).toEqual([
expect(requests.map(({ path }) => path)).toEqual([
"/api/auth/passkeys/registration/options",
"/api/auth/passkeys/registration/complete",
]);
@@ -104,31 +272,100 @@ test("anonymous existing Account page registers a new account via registration e
});
expect(requests[1].body.challenge_id).toBe(registrationOptions.challenge_id);
expect(create).toHaveBeenCalledOnce();
expect(get).not.toHaveBeenCalled();
});
test("authenticated Account locks identity fields and adds only through self-only addition endpoints", async () => {
const { fetcher, create } = boundary(true, 200, false);
render(Account);
const button = await screen.findByRole("button", {
name: "Add passkey to alice",
for (const policy of [false, "error", "loading"] as const) {
test(`register policy ${policy} prevents registration but preserves login and activation links`, async () => {
const { fetcher, create, policyResponse } = boundary({ policy });
render(Register);
await screen.findByRole("heading", { name: "Create account" });
if (policy === false) {
await screen.findByText(/New account registration is disabled/);
}
if (policy === "error") {
await screen.findByRole("button", {
name: "Retry registration availability",
});
}
if (policy === "loading") {
await screen.findByText("Checking registration availability…");
}
await waitFor(() =>
expect(apiPaths(fetcher)).toContain("/api/auth/whoami")
);
expect(screen.queryByLabelText("Account ID (handle)")).toBeNull();
expect(screen.queryByRole("button", { name: "Register new account" }))
.toBeNull();
expect(
screen.getByRole("link", { name: "Log in with an existing account" })
.getAttribute("href"),
)
.toBe("/login?returnTo=%2F");
expect(
screen.getByRole("link", { name: "Activate an account with a code" })
.getAttribute("href"),
)
.toBe("/activate");
expect(posted(fetcher)).toEqual([]);
expect(create).not.toHaveBeenCalled();
if (policy === "loading") {
policyResponse.resolve(
Response.json({ self_registration_enabled: false }),
);
await screen.findByText(/New account registration is disabled/);
}
});
expect(
(screen.getByLabelText("Current user handle") as HTMLInputElement).disabled,
).toBe(true);
expect(
(screen.getByLabelText(
"Display name (new account only)",
) as HTMLInputElement).disabled,
).toBe(true);
expect(
(screen.getByRole("button", {
name: "Log in with passkey",
}) as HTMLButtonElement).disabled,
).toBe(true);
await fireEvent.click(button);
}
test("policy disabled during WebAuthn rejects registration, reloads policy and never falls back to addition", async () => {
const { fetcher, create, state } = boundary();
const credential = gate<Credential | null>();
create.mockReturnValueOnce(credential.promise);
render(Register);
await enterIdentity(true);
await submit("Register new account");
await waitFor(() => expect(create).toHaveBeenCalledOnce());
state.completeStatus = 403;
credential.resolve(credentialFixture());
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(({ path }) => path)).toEqual([
"/api/auth/passkeys/registration/options",
"/api/auth/passkeys/registration/complete",
]);
expect(goto).not.toHaveBeenCalled();
});
test("account displays the current identity without editable target fields and adds only to self", async () => {
const { fetcher, create, get } = boundary({
authenticated: true,
policy: false,
});
render(Account);
await screen.findByRole("button", { name: "Add passkey to alice" });
expect(screen.getByText("alice")).toBeTruthy();
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.queryAllByRole("textbox")).toEqual([]);
expect(screen.queryByRole("button", { name: "Log in with passkey" }))
.toBeNull();
expect(screen.queryByRole("button", { name: "Register new account" }))
.toBeNull();
await fireEvent.click(
screen.getByRole("button", { name: "Add passkey to alice" }),
);
await screen.findByText("Passkey added to your account.");
await waitFor(() =>
expect(
(screen.getByRole("button", { name: "Log out" }) as HTMLButtonElement)
.disabled,
).toBe(false)
);
const requests = posted(fetcher);
expect(requests.map((request) => request.path)).toEqual([
expect(requests.map(({ path }) => path)).toEqual([
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
@@ -137,134 +374,304 @@ test("authenticated Account locks identity fields and adds only through self-onl
"challenge_id",
"credential",
]);
expect(requests.map((request) => request.credentials)).toEqual([
expect(requests[1].body.challenge_id).toBe(registrationOptions.challenge_id);
expect(requests.map(({ credentials }) => credentials)).toEqual([
"same-origin",
"same-origin",
]);
expect(apiPaths(fetcher)).not.toContain("/api/auth/registration-policy");
expect(create).toHaveBeenCalledOnce();
expect(get).not.toHaveBeenCalled();
expect(goto).not.toHaveBeenCalled();
});
test("addition completion rejection is visible and never retries as new registration", async () => {
const { fetcher } = boundary(true, 401);
test("anonymous account redirects to login with an account return path and has no credential inputs", async () => {
const { fetcher } = boundary();
render(Account);
await waitFor(() =>
expect(goto).toHaveBeenCalledWith("/login?returnTo=%2Faccount", {
replaceState: true,
})
);
expect(
screen.getByRole("link", { name: "Log in to your account" }).getAttribute(
"href",
),
)
.toBe("/login?returnTo=%2Faccount");
expect(screen.queryAllByRole("textbox")).toEqual([]);
expect(posted(fetcher)).toEqual([]);
});
for (
const [route, Component] of [
["login", Login],
["register", Register],
["account", Account],
["device", Device],
] as const
) {
test(`${route} keeps loading and unknown whoami separate from an anonymous session`, async () => {
const { fetcher } = boundary();
const response = gate<Response>();
fetcher.mockReturnValueOnce(response.promise);
if (Component === Login) render(Login);
else if (Component === Register) render(Register);
else if (Component === Account) render(Account);
else render(Device);
await screen.findByText("Loading session…");
expect(
screen.queryByRole("button", {
name:
/Log in with passkey|Register new account|Add passkey|Approve device login|Log out/,
}),
).toBeNull();
expect(goto).not.toHaveBeenCalled();
response.resolve(new Response(null, { status: 503 }));
await screen.findByText(/Your session could not be loaded/);
expect(screen.queryByText("No browser session is active.")).toBeNull();
expect(
screen.queryByRole("button", {
name:
/Log in with passkey|Register new account|Add passkey|Approve device login|Log out/,
}),
).toBeNull();
expect(posted(fetcher)).toEqual([]);
expect(goto).not.toHaveBeenCalled();
});
}
test("unknown account session shows retry instead of treating errors as anonymous or redirecting", async () => {
const { state, fetcher } = boundary();
state.whoamiStatus = 503;
render(Account);
await screen.findByText(/Your session could not be loaded/);
expect(screen.queryByText("No browser session is active.")).toBeNull();
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
expect(goto).not.toHaveBeenCalled();
state.whoamiStatus = 200;
state.authenticated = true;
await fireEvent.click(screen.getByRole("button", { name: "Retry session" }));
await screen.findByRole("button", { name: "Add passkey to alice" });
expect(posted(fetcher)).toEqual([]);
expect(goto).not.toHaveBeenCalled();
});
test("logout posts to the session endpoint and confirms anonymous whoami before login navigation", async () => {
const { fetcher } = boundary({ authenticated: true });
render(Account);
await fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
await waitFor(() =>
expect(goto).toHaveBeenCalledWith("/login?returnTo=%2Faccount", {
replaceState: true,
})
);
expect(posted(fetcher)).toEqual([
{ path: "/api/auth/logout", body: {}, credentials: "same-origin" },
]);
expect(apiPaths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/logout",
"/api/auth/whoami",
]);
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
});
test("expired addition revokes stale identity without silently retrying as registration", async () => {
const { fetcher } = boundary({ authenticated: true, completeStatus: 401 });
render(Account);
await fireEvent.click(
await screen.findByRole("button", { name: "Add passkey to alice" }),
);
await screen.findByText("Auth request failed (401).");
expect(posted(fetcher).map((request) => request.path)).toEqual([
await screen.findByText(/Your session or authentication challenge expired/);
await screen.findByRole("link", { name: "Log in to your account" });
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
expect(screen.queryByText("Passkey added to your account.")).toBeNull();
expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
});
test("blank handle disables anonymous registration and login", async () => {
boundary(false);
render(Account);
await screen.findByText("No browser session is active.");
await fireEvent.input(
screen.getByLabelText("User handle (new registration or login)"),
{ target: { value: " " } },
);
test("pending login disables inputs and rejects duplicate form submits until the credential completes", async () => {
const { fetcher, get } = boundary();
const credential = gate<Credential | null>();
get.mockReturnValueOnce(credential.promise);
render(Login);
await enterIdentity();
await submit("Log in with passkey");
await waitFor(() => expect(get).toHaveBeenCalledOnce());
expect(
(screen.getByRole("button", {
name: "Register new account",
}) as HTMLButtonElement).disabled,
(screen.getByLabelText("Account ID (handle)") as HTMLInputElement).disabled,
).toBe(true);
expect(
(screen.getByRole("button", {
name: "Log in with passkey",
}) as HTMLButtonElement).disabled,
).toBe(true);
});
test("unknown session never enables registration or addition", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 503 })),
);
render(Account);
await screen.findByText("Auth request failed (503).");
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",
await submit("Log in with passkey");
expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/passkeys/login/options",
]);
expect(goto).not.toHaveBeenCalled();
credential.resolve(credentialFixture(true));
await waitFor(() => expect(goto).toHaveBeenCalledOnce());
expect(get).toHaveBeenCalledOnce();
expect(posted(fetcher)).toHaveLength(2);
});
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();
test("WebAuthn cancellation is visible, sends no completion, and allows an explicit retry", async () => {
const { fetcher, get } = boundary();
get.mockRejectedValueOnce(
new DOMException("sensitive browser detail", "NotAllowedError"),
);
render(Login);
await enterIdentity();
await submit("Log in with passkey");
await screen.findByText(
"Passkey operation was cancelled or timed out. You can try again.",
);
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Log in with passkey",
}) as HTMLButtonElement).disabled,
).toBe(false);
expect(posted(fetcher)).toEqual([]);
});
}
).toBe(false)
);
expect(screen.queryByText(/sensitive browser detail/)).toBeNull();
expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/passkeys/login/options",
]);
expect(goto).not.toHaveBeenCalled();
await submit("Log in with passkey");
await waitFor(() => expect(goto).toHaveBeenCalledOnce());
expect(get).toHaveBeenCalledTimes(2);
});
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" }),
test("unmount aborts a pending WebAuthn login and ignores a credential that arrives afterwards", async () => {
const { fetcher, get } = boundary();
const credential = gate<Credential | null>();
get.mockReturnValueOnce(credential.promise);
const view = render(Login);
await enterIdentity();
await submit("Log in with passkey");
await waitFor(() => expect(get).toHaveBeenCalledOnce());
const signal = get.mock.calls[0][0]?.signal;
expect(signal?.aborted).toBe(false);
view.unmount();
expect(signal?.aborted).toBe(true);
credential.resolve(credentialFixture(true));
await credential.promise;
await tick();
expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/passkeys/login/options",
]);
expect(goto).not.toHaveBeenCalled();
});
test("device login round trip preserves the code and still requires an explicit approval", async () => {
const { fetcher } = boundary();
page.url = new URL("https://example.test/login/device?user_code=abcd-efgh");
const device = render(Device);
const login = await screen.findByRole("link", { name: "Log in to continue" });
expect(screen.queryByRole("button", { name: "Approve device login" }))
.toBeNull();
expect(posted(fetcher)).toEqual([]);
const destination = new URL(login.getAttribute("href")!, page.url);
expect(destination.pathname).toBe("/login");
expect(destination.searchParams.get("returnTo")).toBe(
"/login/device?user_code=abcd-efgh",
);
device.unmount();
page.url = destination;
const signIn = render(Login);
await enterIdentity();
await submit("Log in with passkey");
await waitFor(() =>
expect(goto).toHaveBeenCalledWith("/login/device?user_code=abcd-efgh", {
replaceState: true,
})
);
expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve");
signIn.unmount();
page.url = new URL("https://example.test/login/device?user_code=abcd-efgh");
render(Device);
const approve = await screen.findByRole("button", {
name: "Approve device login",
});
expect((screen.getByLabelText("User code") as HTMLInputElement).value).toBe(
"abcd-efgh",
);
expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve");
await fireEvent.submit(approve.closest("form")!);
await screen.findByText("Device login approved. You can return to the CLI.");
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Approve device login" }))
.toBeNull()
);
expect(
posted(fetcher).filter(({ path }) =>
path === "/api/auth/device-login/approve"
),
)
.toEqual([{
path: "/api/auth/device-login/approve",
body: { user_code: "ABCD-EFGH" },
credentials: "same-origin",
}]);
await fireEvent.submit(screen.getByLabelText("User code").closest("form")!);
expect(
posted(fetcher).filter(({ path }) =>
path === "/api/auth/device-login/approve"
),
).toHaveLength(1);
});
test("device approval locks its code and rejects a second submit while the server response is pending", async () => {
const { fetcher } = boundary({ authenticated: true });
page.url = new URL("https://example.test/login/device?user_code=abcd-efgh");
render(Device);
const approve = await screen.findByRole("button", {
name: "Approve device login",
});
const response = gate<Response>();
fetcher.mockReturnValueOnce(response.promise);
const form = approve.closest("form")!;
await fireEvent.submit(form);
await screen.findByText("Approving device login…");
expect((approve as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByLabelText("User code") as HTMLInputElement).disabled)
.toBe(true);
await fireEvent.submit(form);
expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/device-login/approve",
]);
response.resolve(Response.json({ status: "approved", user: authUser }));
await screen.findByText("Device login approved. You can return to the CLI.");
expect(posted(fetcher)).toHaveLength(1);
});
for (const status of [401, 410]) {
test(`device approval ${status} is visible and never reports approval`, async () => {
const { fetcher } = boundary({
authenticated: true,
completeStatus: status,
});
page.url = new URL("https://example.test/login/device?code=abcd-efgh");
render(Device);
await fireEvent.submit(
(await screen.findByRole("button", { name: "Approve device login" }))
.closest("form")!,
);
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",
]);
await screen.findByText(
status === 401
? /Your session or authentication challenge expired/
: /The request or authentication challenge is invalid or expired/,
);
expect(
screen.queryByText("Device login approved. You can return to the CLI."),
).toBeNull();
expect(posted(fetcher)).toHaveLength(1);
expect(goto).not.toHaveBeenCalled();
});
}
@@ -29,12 +29,12 @@
notifySessionChanged();
} catch {
if (current !== generation) return;
error = 'Registration completion could not be confirmed. If you completed the Passkey prompt, check Passkey login from Account first. Otherwise re-enter the ID and code to restart. Ask your administrator to reissue if the code expired, was locked or was replaced.';
error = 'Registration completion could not be confirmed. If you completed the Passkey prompt, check the Passkey login page first. Otherwise re-enter the ID and code to restart. Ask your administrator to reissue if the code expired, was locked or was replaced.';
} finally { if (current === generation) { code = ''; busy = false; controller = null; } }
}
function cancel() {
++generation; controller?.abort(); controller = null; code = ''; busy = false;
error = 'Registration stopped; completion is not confirmed. If you completed the Passkey prompt, check Passkey login from Account first. Otherwise re-enter the ID and code to restart.';
error = 'Registration stopped; completion is not confirmed. If you completed the Passkey prompt, check the Passkey login page first. Otherwise re-enter the ID and code to restart.';
}
onMount(() => {
const clear = () => { ++generation; controller?.abort(); code = ''; busy = false; };
@@ -66,7 +66,7 @@
</form>
{#if busy}<p role="status">Passkey registration pending. Account activation has not been confirmed.</p>{/if}
{#if error}<p role="alert" class="error">{error}</p>{/if}
<a href="/account">Already registered or completion uncertain? Check Passkey login from Account</a>
<a href="/login">Already registered or completion uncertain? Log in with a Passkey</a>
<details><summary>Restart or get a new code</summary><p>Allow the browser’s Passkey prompt and save a Passkey. Only a confirmed completion signs you in; checking the code alone gives no normal session, Workspace access or device approval. After cancellation or reload, enter the ID and code again. A correct code is not consumed by starting the prompt. If a request lost its response, try Passkey login before restarting. If no Passkey works, ask your administrator to reissue for the pending ID; reissue invalidates the old code and prompt. This works even when self-registration is disabled.</p></details>
{/if}
</div>
@@ -0,0 +1,5 @@
<script lang="ts">
import AuthEntry from '#lib/workspace/auth/AuthEntry.svelte';
</script>
<AuthEntry mode="login" />
@@ -1,119 +1,55 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import {
approveDeviceLogin,
loadWhoami,
loginWithPasskey,
} from '#lib/workspace/auth/api.ts';
import '#lib/workspace/styles/settings.css';
import type { RequestActor } from '#lib/workspace/auth/model.ts';
import { approveDeviceLogin } from '#lib/workspace/auth/api.ts';
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
import { loginUrl } from '#lib/workspace/auth/routes.ts';
import '#lib/workspace/auth/auth-page.css';
let actor = $state<RequestActor | null>(null);
let handle = $state('local');
const auth = new BrowserAuth();
let userCode = $state('');
let loading = $state(true);
let busy = $state(false);
let status = $state('');
let error = $state('');
async function refreshWhoami() {
loading = true;
error = '';
try {
const whoami = await loadWhoami();
actor = whoami.actor;
if (actor?.handle) handle = actor.handle;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function login() {
busy = true;
status = 'Waiting for passkey login…';
error = '';
try {
await loginWithPasskey(handle.trim());
status = 'Logged in. You can approve the CLI login now.';
await refreshWhoami();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
status = '';
} finally {
busy = false;
}
}
let approved = $state(false);
const login = $derived(loginUrl(`/login/device?${new URLSearchParams({ user_code: userCode.trim() })}`));
async function approve() {
busy = true;
status = 'Approving device login…';
error = '';
try {
const result = await approveDeviceLogin(userCode.trim().toUpperCase());
status = result.status === 'approved'
? 'Device login approved. You can return to the CLI.'
: `Device login status: ${result.status}`;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
status = '';
} finally {
busy = false;
}
if (auth.busy || !auth.actor || approved || !userCode.trim()) return;
const code = userCode.trim().toUpperCase();
await auth.run('Approving device login…', 'Device login approved. You can return to the CLI.', async (signal) => {
const result = await approveDeviceLogin(code, signal);
if (result.status !== 'approved') throw new Error('Device approval not confirmed');
approved = true;
});
}
onMount(() => {
userCode = page.url.searchParams.get('user_code') ?? page.url.searchParams.get('code') ?? '';
void refreshWhoami();
void auth.refresh();
return () => auth.dispose();
});
</script>
<svelte:head>
<title>Device Login · Yoi Workspace</title>
</svelte:head>
<main class="settings-page">
<header class="settings-header">
<p class="eyebrow">Device Login</p>
<h1>Approve CLI login</h1>
<p>Use your browser session to approve a pending CLI/TUI login request.</p>
</header>
<section class="settings-panel">
<h2>Browser session</h2>
{#if loading}
<p class="muted">Loading session…</p>
{:else if actor}
<p>Logged in as <strong>{actor.display_name}</strong> <span class="muted">@{actor.handle}</span>.</p>
{:else}
<p class="muted">You need to log in with a passkey before approving a device login.</p>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void login(); }}>
<label>
User handle
<input bind:value={handle} autocomplete="username webauthn" placeholder="local" />
</label>
<button type="submit" disabled={busy || !handle.trim()}>Log in with passkey</button>
</form>
<svelte:head><title>Device Login · Yoi</title></svelte:head>
<div class="auth-page" data-sveltekit-preload-data="off">
<h1>Approve CLI login</h1>
<p>Only approve a code shown by a CLI login you started. Logging in to the browser does not approve the device.</p>
{#if auth.session.kind === 'loading'}
<p class="muted" role="status">Loading session…</p>
{:else if auth.session.kind === 'error'}
<p class="error" role="alert">Your session could not be loaded. Login status is unknown.</p>
<button type="button" disabled={auth.busy} onclick={() => auth.refresh()}>Retry session</button>
{:else if auth.actor}
<p>Logged in as <strong>{auth.actor.display_name}</strong> <span class="muted">@{auth.actor.handle}</span>.</p>
{:else}
<p>You need to log in before approving a device login.</p>
<a href={login}>Log in to continue</a>
{/if}
<form onsubmit={(event) => { event.preventDefault(); void approve(); }}>
<label>User code
<input bind:value={userCode} required maxlength="64" placeholder="ABCD-EFGH" autocapitalize="characters" disabled={auth.busy || approved} />
</label>
{#if auth.actor && !approved}
<button type="submit" disabled={auth.busy || !userCode.trim()}>Approve device login</button>
{/if}
</section>
<section class="settings-panel">
<h2>Approval code</h2>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void approve(); }}>
<label>
User code
<input bind:value={userCode} placeholder="ABCD-EFGH" autocapitalize="characters" />
</label>
<button type="submit" disabled={busy || !actor || !userCode.trim()}>Approve device login</button>
</form>
</section>
{#if status}
<p class="status-message">{status}</p>
{/if}
{#if error}
<p class="error-message">{error}</p>
{/if}
</main>
</form>
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.error}<p class="error" role="alert">{auth.error}</p>{/if}
<a href="/account">Open Account</a>
</div>
@@ -0,0 +1,5 @@
<script lang="ts">
import AuthEntry from '#lib/workspace/auth/AuthEntry.svelte';
</script>
<AuthEntry mode="register" />
@@ -172,7 +172,7 @@ Deno.test("activation standalone shell supports issuance, reissue, denial, resta
await checkLink(
page.getByRole("link", {
name:
"Already registered or completion uncertain? Check Passkey login from Account",
"Already registered or completion uncertain? Log in with a Passkey",
}),
);
await fillActivation();
@@ -0,0 +1,704 @@
/// <reference lib="dom" />
// 本番shell/router/clientを合成HTTP/WebAuthnで接続する。Server認証/DBのE2Eではない。
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { type Browser, chromium, type Page } from "playwright";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const origin = "http://127.0.0.1:15391";
type Mutation = { path: string; keys: string[] };
type Harness = {
page: Page;
mutations: Mutation[];
go: (path: string, mode?: string) => Promise<void>;
};
const login = async (page: Page) => {
await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice");
await page.getByRole("button", { name: "Log in with passkey", exact: true })
.click();
};
const register = async (page: Page) => {
await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice");
await page.getByLabel("Display name", { exact: true }).fill("Alice");
await page.getByRole("button", { name: "Register new account", exact: true })
.click();
};
const count = (mutations: Mutation[], path: string) =>
mutations.filter((entry) => entry.path === path).length;
const at = async (page: Page, path: string) => {
await page.waitForURL((url) =>
url.origin === origin && url.pathname + url.search === path
);
};
const noOverflow = async (page: Page) =>
assertEquals(
await page.evaluate(() =>
document.documentElement.scrollWidth > innerWidth
),
false,
);
async function isolated(
browser: Browser,
body: (harness: Harness) => Promise<void>,
colorScheme: "light" | "dark" = "light",
width = 1440,
) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
// 不正returnToでも外部へ接続しない。遷移した事実は下のURL assertionで失敗する。
await context.route("**/*", (route) => {
const url = new URL(route.request().url());
return url.origin === origin ? route.continue() : route.abort();
});
const page = await context.newPage();
const mutations: Mutation[] = [];
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("request", (request) => {
const path = new URL(request.url()).pathname;
if (request.method() === "POST" && path.startsWith("/api/")) {
// credential本文/secretは保持せず、対象を自由指定しない契約のfield名だけを観測する。
mutations.push({
path,
keys: Object.keys(request.postDataJSON() ?? {}).sort(),
});
}
});
await body({
page,
mutations,
go: async (path, mode = "anonymous") => {
const url = new URL(path, origin);
url.searchParams.set("fixture", mode);
await page.goto(url.href);
},
});
await noOverflow(page);
assertEquals(errors, []);
} finally {
await context.close();
}
}
Deno.test("standalone auth separates login, registration, own account and explicit device approval", async (t) => {
const processes = await startOwnedProcesses(
[{
id: "auth-browser",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/auth-browser/fixture-server.ts",
"15391",
"web/workspace/build",
],
readyUrl: `${origin}/health`,
}],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
`${root}/target/web-ux/auth-interactions/processes`,
[],
);
let browser: Browser | undefined;
try {
browser = await chromium.launch();
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const step = (
name: string,
body: (harness: Harness) => Promise<void>,
) =>
t.step(
`${theme}/${width}: ${name}`,
() => isolated(browser!, body, theme, width),
);
await step(
"anonymous account replaces history with login then returns to own account",
async ({ page, go, mutations }) => {
await page.goto(`${origin}/health`);
const historyBefore = await page.evaluate(() => history.length);
await go("/account");
await at(page, "/login?returnTo=%2Faccount");
assertEquals(
await page.evaluate(() => history.length),
historyBefore + 1,
);
const id = page.getByLabel("Account ID (handle)", { exact: true });
await id.waitFor();
assertEquals(
await page.getByLabel("Display name", { exact: true }).count(),
0,
);
assertEquals(await page.locator("input").count(), 1);
assertEquals(
await page.getByRole("link", {
name: "Create a new account",
exact: true,
}).getAttribute("href"),
"/register?returnTo=%2Faccount",
);
await page.getByRole("link", {
name: "Activate an account with a code",
exact: true,
}).waitFor();
await id.fill("alice");
await id.press("Tab");
const button = page.getByRole("button", {
name: "Log in with passkey",
exact: true,
});
assert(
await button.evaluate((element) =>
element === document.activeElement &&
getComputedStyle(element).outlineStyle !== "none"
),
);
await page.keyboard.press("Enter");
await at(page, "/account");
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).waitFor();
assertEquals(await page.locator("input").count(), 0);
await page.getByText("Alice", { exact: true }).first().waitFor();
await noOverflow(page);
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).click();
await page.getByRole("status").filter({
hasText: /added|registered/i,
}).waitFor();
const additions = mutations.filter((entry) =>
entry.path.includes("/addition/")
);
assertEquals(additions.map((entry) => entry.path), [
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
assertEquals(additions[0].keys, ["browser_origin"]);
assertEquals(additions[1].keys, ["challenge_id", "credential"]);
assertEquals(
mutations.some((entry) => entry.path.includes("/registration/")),
false,
);
await page.getByRole("button", { name: "Log out", exact: true })
.click();
await page.getByRole("button", {
name: "Log in with passkey",
exact: true,
}).waitFor();
assertEquals(new URL(page.url()).pathname, "/login");
await page.reload();
await page.getByRole("button", {
name: "Log in with passkey",
exact: true,
}).waitFor();
assertEquals(count(mutations, "/api/auth/logout"), 1);
},
);
await step(
"direct registration has only ID and display name and returns safely",
async ({ page, go, mutations }) => {
await go("/register?returnTo=%2Faccount");
await page.getByLabel("Display name", { exact: true }).waitFor();
assertEquals(await page.locator("input").count(), 2);
await register(page);
await at(page, "/account");
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).waitFor();
assertEquals(
count(mutations, "/api/auth/passkeys/registration/options"),
1,
);
assertEquals(
count(mutations, "/api/auth/passkeys/registration/complete"),
1,
);
assertEquals(
mutations.some((entry) => entry.path.includes("/addition/")),
false,
);
},
);
await step(
"device login restoration never approves without an explicit separate action",
async ({ page, go, mutations }) => {
await go("/login/device?user_code=ABCD-EFGH");
await page.getByLabel("User code", { exact: true }).fill(
"WXYZ-1234",
);
const entry = page.getByRole("link", {
name: "Log in to continue",
exact: true,
});
const href = await entry.getAttribute("href");
assert(href);
const returned = new URL(href, origin).searchParams.get("returnTo");
assert(returned?.startsWith("/login/device?"));
assertEquals(
new URL(returned!, origin).searchParams.get("user_code"),
"WXYZ-1234",
);
assertEquals(
await page.getByLabel("Account ID (handle)", { exact: true })
.count(),
0,
);
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
await entry.click();
await login(page);
await page.waitForURL((url) => url.pathname === "/login/device");
const approve = page.getByRole("button", {
name: "Approve device login",
exact: true,
});
await approve.waitFor();
assertEquals(
await page.getByLabel("User code", { exact: true }).inputValue(),
"WXYZ-1234",
);
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
await approve.click();
await page.getByText(
"Device login approved. You can return to the CLI.",
{ exact: true },
).waitFor();
assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
},
);
await step(
"disabled registration retains existing-user login",
async ({ page, go, mutations }) => {
await go("/register", "policy-disabled");
await page.getByText(
"New account registration is disabled. Existing users can still log in.",
{ exact: true },
).waitFor();
assertEquals(
await page.getByRole("button", {
name: "Register new account",
exact: true,
}).count(),
0,
);
assertEquals(
await page.getByLabel("Account ID (handle)", { exact: true })
.count(),
0,
);
const link = page.locator('a[href^="/login"]').first();
await link.click();
await login(page);
await at(page, "/");
await page.goto(`${origin}/account`);
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).click();
await page.getByRole("status").filter({ hasText: /added/i })
.waitFor();
assertEquals(
count(mutations, "/api/auth/passkeys/addition/complete"),
1,
);
assertEquals(
mutations.some((entry) => entry.path.includes("/registration/")),
false,
);
},
);
await step(
"registration stopped during ceremony displays HTTP403 and removes registration after policy refresh",
async ({ page, go, mutations }) => {
await go("/register", "policy-stopped");
await register(page);
await page.getByRole("alert").filter({ hasText: "403" }).waitFor();
await page.getByText(
"New account registration is disabled. Existing users can still log in.",
{ exact: true },
).waitFor();
assertEquals(await page.locator("form").count(), 0);
assertEquals(
await page.getByRole("button", {
name: "Register new account",
exact: true,
}).count(),
0,
);
await page.getByRole("link", {
name: "Log in with an existing account",
exact: true,
}).waitFor();
assertEquals(new URL(page.url()).pathname, "/register");
assertEquals(
count(mutations, "/api/auth/passkeys/registration/complete"),
1,
);
await noOverflow(page);
},
);
for (
const mode of [
"session-error",
"session-malformed",
"session-loading",
]
) {
await step(
`${mode} does not become known anonymous or allow mutation`,
async ({ page, go, mutations }) => {
await go("/login", mode);
if (mode === "session-loading") {
await page.getByText("Loading session…", { exact: true })
.waitFor();
} else {
await page.getByRole("alert").waitFor();
await page.getByRole("button", {
name: "Retry session",
exact: true,
}).waitFor();
}
const loginButton = page.getByRole("button", {
name: "Log in with passkey",
exact: true,
});
if (await loginButton.count()) {
assertEquals(await loginButton.isEnabled(), false);
}
assertEquals(mutations, []);
},
);
}
}
}
for (
const path of [
"/account",
"/register",
"/login/device?user_code=ABCD-EFGH",
]
) {
await t.step(
`unknown session on ${path} offers only recovery, never anonymous mutations`,
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go(path, "session-error");
await page.getByRole("button", {
name: "Retry session",
exact: true,
}).waitFor();
assertEquals(
await page.getByRole("link", {
name: "Log in to continue",
exact: true,
}).count(),
0,
);
assertEquals(
await page.getByLabel("Account ID (handle)", { exact: true })
.count(),
0,
);
assertEquals(
await page.getByRole("button", {
name:
/^(Add passkey|Register new account|Approve device login)/,
}).count(),
0,
);
assertEquals(
new URL(page.url()).pathname,
new URL(path, origin).pathname,
);
assertEquals(mutations, []);
}),
);
}
await t.step(
"policy lookup failure is unknown availability, not disabled or permitted registration",
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go("/register", "policy-error");
await page.getByRole("alert").waitFor();
assertEquals(
await page.getByRole("button", {
name: "Register new account",
exact: true,
}).count(),
0,
);
await page.getByRole("link", {
name: "Log in with an existing account",
exact: true,
}).waitFor();
assertEquals(mutations, []);
}),
);
await t.step(
"leaving a pending login never completes or redirects after navigation",
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go("/login", "login-pending");
await login(page);
await page.waitForFunction(() =>
typeof (window as unknown as { __authFixtureRelease?: unknown })
.__authFixtureRelease === "function"
);
await page.getByRole("link", {
name: "Create a new account",
exact: true,
}).click();
await page.getByLabel("Display name", { exact: true }).waitFor();
await page.evaluate(() =>
(window as unknown as { __authFixtureRelease: () => void })
.__authFixtureRelease()
);
// 次のpaintまで旧promiseのmicrotaskを進め、固定sleepで完了を推測しない。
await page.evaluate(() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => resolve())
)
)
);
assertEquals(new URL(page.url()).pathname, "/register");
assertEquals(
count(mutations, "/api/auth/passkeys/login/complete"),
0,
);
}),
);
await t.step(
"retry rechecks unknown session before allowing login",
() =>
isolated(browser!, async ({ page, go }) => {
await go("/login", "session-retry");
await page.getByRole("button", { name: "Retry session", exact: true })
.click();
await login(page);
await at(page, "/");
}),
);
for (const route of ["login", "register"]) {
await t.step(
`${route} without returnTo uses root fallback`,
() =>
isolated(browser!, async ({ page, go }) => {
await go(`/${route}`);
await (route === "login" ? login(page) : register(page));
await at(page, "/");
await page.reload();
assertEquals(new URL(page.url()).pathname, "/");
}),
);
for (
const rejected of [
"https://example.invalid/",
"//example.invalid/",
"/\\example.invalid/",
"account",
"/login",
"/register?returnTo=%2Flogin",
"/activate",
"/login/../login",
]
) {
await t.step(
`${route} rejects external or auth-loop returnTo ${rejected}`,
() =>
isolated(browser!, async ({ page, go }) => {
await go(`/${route}?returnTo=${encodeURIComponent(rejected)}`);
await (route === "login" ? login(page) : register(page));
await at(page, "/");
}),
);
}
}
for (const purpose of ["login", "registration", "addition"] as const) {
const route = purpose === "registration"
? "/register"
: purpose === "addition"
? "/account"
: "/login";
for (const failure of ["cancel", "error", "http-error", "expired"]) {
await t.step(
`${purpose} ${failure} is visible and does not claim successful authentication`,
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go(route, `${purpose}-${failure}`);
if (purpose === "login") await login(page);
else if (purpose === "registration") await register(page);
else {await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).click();}
await page.getByRole("alert").waitFor();
const path = new URL(page.url()).pathname;
assert(
path === route || (purpose === "addition" && path === "/login"),
);
if (
failure === "cancel" || failure === "error" ||
failure === "http-error"
) {
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
0,
);
} else {assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
1,
);}
}),
);
}
await t.step(
`${purpose} repeated submit while prompt is pending sends one ceremony`,
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go(route, `${purpose}-pending`);
const label = purpose === "login"
? "Log in with passkey"
: purpose === "registration"
? "Register new account"
: "Add passkey to alice";
if (purpose === "login") await login(page);
else if (purpose === "registration") await register(page);
else {await page.getByRole("button", { name: label, exact: true })
.click();}
await page.waitForFunction(() =>
typeof (window as unknown as { __authFixtureRelease?: unknown })
.__authFixtureRelease === "function"
);
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/options`),
1,
);
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
0,
);
await page.getByRole("button", { name: label, exact: true })
.evaluate((button) => {
button.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
button.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
await page.locator("form").evaluateAll((forms) => {
for (const form of forms) {
form.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
form.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
}
});
await page.evaluate(() =>
(window as unknown as { __authFixtureRelease: () => void })
.__authFixtureRelease()
);
if (purpose === "addition") {
await page.getByRole("status").filter({
hasText: /added|registered/i,
}).waitFor();
} else await at(page, "/");
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/options`),
1,
);
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
1,
);
}),
);
}
await t.step(
"expired device request stays unapproved",
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go("/login/device?user_code=ABCD-EFGH", "device-expired");
await page.getByRole("link", {
name: "Log in to continue",
exact: true,
}).click();
await login(page);
await page.getByRole("button", {
name: "Approve device login",
exact: true,
}).click();
await page.getByRole("alert").filter({ hasText: /invalid|expired/i })
.waitFor();
assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
}),
);
await t.step(
"activation entry remains limited until credential completion",
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go("/login", "activation-pending");
await page.getByRole("link", {
name: "Activate an account with a code",
exact: true,
}).click();
// 同名labelのlogin入力が残る間にfillしない。遷移先の固有controlで同期する。
await page.waitForURL((url) => url.pathname === "/activate");
await page.locator("#activation-handle").fill("alice");
await page.locator("#activation-code").fill(
"synthetic-input-only",
);
await page.getByRole("button", {
name: "Register Passkey and activate",
exact: true,
}).click();
await page.getByText(
"Passkey registration pending. Account activation has not been confirmed.",
{ exact: true },
).waitFor();
assertEquals(count(mutations, "/api/auth/activation/complete"), 0);
assertEquals(
mutations.some((entry) =>
/device-login|addition|registration/.test(entry.path)
),
false,
);
assertEquals(await page.locator(".app-shell").count(), 0);
await page.getByRole("button", {
name: "Cancel registration",
exact: true,
}).click();
await page.getByRole("alert").waitFor();
const link = page.locator('a[href="/login"]');
await link.waitFor();
await link.click();
await page.waitForURL((url) => url.pathname === "/login");
await page.getByRole("button", {
name: "Log in with passkey",
exact: true,
}).waitFor();
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
}),
);
} finally {
await browser?.close();
await stopOwnedProcesses(processes);
}
});
+327
View File
@@ -0,0 +1,327 @@
// shared captureがscreenshot/snapshot/review-contextを採取する。合否と本人目視は呼出元が行う。
// 本番buildに合成HTTP/WebAuthnだけを接続し、撮影専用CSSや認証情報は使わない。
import { resolve } from "@std/path";
import { capture } from "../../../../tools/web-ux/src/capture.ts";
import type {
Interaction,
RouteScenario,
Scenario,
} from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const phase = Deno.args[0] ?? "after";
if (!/^[a-zA-Z0-9_-]+$/.test(phase)) {
throw new Error("Expected safe capture label");
}
const ready = (selector: string) => ({ kind: "selector" as const, selector });
const click = (selector: string): Interaction => ({
action: "click",
selector,
});
const fill = (selector: string, value: string): Interaction => ({
action: "fill",
selector,
value,
});
const id = 'label:has-text("ID (handle)") input';
const login = [
fill(id, "alice"),
click('button:text-is("Log in with passkey")'),
];
const register = [
fill(id, "alice"),
fill('label:has-text("Display name") input', "Alice"),
click('button:text-is("Register new account")'),
];
const route = (
id: string,
path: string,
selector: string,
capturePoints: RouteScenario["capturePoints"] = [{
id: "initial",
label: id,
fullPage: true,
}],
): RouteScenario => ({
id,
label: id,
path,
goal:
"Review standalone production auth UI, navigation, focus, state and copy; not Server authorization.",
dataState: `Synthetic ${id}; no real session, credential or activation code.`,
ready: ready(selector),
capturePoints,
});
const routes: RouteScenario[] = [
route("login", "/login?fixture=anonymous", `${id}:enabled`, [
{
id: "initial",
label: "ID-only login; registration and activation entries",
fullPage: true,
},
{
id: "keyboard-focus",
label: "Login keyboard focus",
fullPage: true,
interaction: [fill(id, "alice"), {
action: "press",
selector: id,
key: "Tab",
}],
ready: ready('button:text-is("Log in with passkey"):focus-visible'),
},
]),
route(
"account-redirect",
"/account?fixture=anonymous",
'button:text-is("Log in with passkey")',
),
route("register", "/register?fixture=anonymous", `${id}:enabled`, [
{
id: "initial",
label: "ID and distinct display name; new account only",
fullPage: true,
},
{
id: "keyboard-focus",
label: "Register keyboard focus",
fullPage: true,
interaction: [
fill(id, "alice"),
fill('label:has-text("Display name") input', "Alice"),
{
action: "press",
selector: 'label:has-text("Display name") input',
key: "Tab",
},
],
ready: ready('button:text-is("Register new account"):focus-visible'),
},
]),
route(
"account",
"/account?fixture=account",
'button:text-is("Add passkey to alice")',
[
{
id: "initial",
label: "Own account only; no target handle field",
fullPage: true,
},
{
id: "addition-confirmed",
label: "Own Passkey addition confirmed",
fullPage: true,
interaction: [click('button:text-is("Add passkey to alice")')],
ready: ready('[role="status"]:has-text("Passkey added")'),
},
{
id: "logout",
label: "Log out returns to login",
fullPage: true,
interaction: [click('button:text-is("Log out")')],
ready: ready('button:text-is("Log in with passkey")'),
},
],
),
route(
"device-anonymous",
"/login/device?user_code=ABCD-EFGH&fixture=anonymous",
'a:text-is("Log in to continue")',
[
{
id: "initial",
label: "User code and login continuation; no implicit approval",
fullPage: true,
},
{
id: "login",
label: "Dedicated login with device returnTo",
fullPage: true,
interaction: [click('a:text-is("Log in to continue")')],
ready: ready(`${id}:enabled`),
},
{
id: "authenticated-unapproved",
label: "Login complete; explicit approval still required",
fullPage: true,
interaction: login,
ready: ready('button:text-is("Approve device login"):enabled'),
},
{
id: "approved",
label: "Separate explicit approval confirmed",
fullPage: true,
interaction: [click('button:text-is("Approve device login")')],
ready: ready(
'text="Device login approved. You can return to the CLI."',
),
},
],
),
route(
"session-loading",
"/login?fixture=session-loading",
'text="Loading session…"',
),
route(
"session-error",
"/login?fixture=session-error",
'button:text-is("Retry session")',
),
route(
"account-session-error",
"/account?fixture=session-error",
'button:text-is("Retry session")',
),
route(
"register-session-error",
"/register?fixture=session-error",
'button:text-is("Retry session")',
),
route(
"device-session-error",
"/login/device?user_code=ABCD-EFGH&fixture=session-error",
'button:text-is("Retry session")',
),
route(
"policy-disabled",
"/register?fixture=policy-disabled",
'text="New account registration is disabled. Existing users can still log in."',
),
route("policy-error", "/register?fixture=policy-error", '[role="alert"]'),
route("policy-stopped", "/register?fixture=policy-stopped", `${id}:enabled`, [
{
id: "completion-403",
label:
"Policy refreshed to disabled after intentional registration HTTP403",
fullPage: true,
interaction: register,
ready: ready(
'text="New account registration is disabled. Existing users can still log in."',
),
},
]),
...["login", "registration", "addition"].flatMap((purpose) =>
["pending", "cancel", "error", "expired"].map((state) => {
const path = purpose === "registration"
? "/register"
: purpose === "addition"
? "/account"
: "/login";
const interaction = purpose === "login"
? login
: purpose === "registration"
? register
: [click('button:text-is("Add passkey to alice")')];
return route(
`${purpose}-${state}`,
`${path}?fixture=${purpose}-${state}`,
purpose === "addition"
? 'button:text-is("Add passkey to alice")'
: `${id}:enabled`,
[{
id: "result",
label: `${purpose} ${state}; not a successful ceremony`,
fullPage: true,
interaction,
ready: ready(
state === "pending" ? '[role="status"]' : '[role="alert"]',
),
}],
);
})
),
route(
"activation-entry",
"/login?fixture=activation-pending",
'a:text-is("Activate an account with a code")',
[
{
id: "entry",
label: "Existing activation route from login",
fullPage: true,
interaction: [click('a:text-is("Activate an account with a code")')],
ready: ready("#activation-code:enabled"),
},
{
id: "limited",
label:
"No ordinary session or device approval before Passkey completion",
fullPage: true,
interaction: [
fill("#activation-handle", "alice"),
fill("#activation-code", "synthetic-input-only"),
click('button:text-is("Register Passkey and activate")'),
],
ready: ready('button:text-is("Cancel registration")'),
},
{
id: "cancelled",
label: "Activation cancellation and login recovery entry",
fullPage: true,
interaction: [click('button:text-is("Cancel registration")')],
ready: ready('[role="alert"]'),
},
],
),
];
await Deno.mkdir(`${root}/target/web-ux`, { recursive: true });
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const scenario: Scenario = {
schemaVersion: 1,
id: `auth-${colorScheme}-${width}`,
title:
"Standalone authentication synthetic projection on production build",
baseUrl: "http://127.0.0.1:15392",
colorScheme,
reducedMotion: "reduce",
personas: [{
id: "fixture",
label: "Synthetic HTTP/WebAuthn only",
auth: { kind: "anonymous" },
}],
viewports: [{ width, height: 900, label: `${width}` }],
routes,
redact: {
// 初期撮影後にもfillする。空の入力まで恒久的に隠すと次の操作が不能になる。
// 合成codeはsubmit直後に破棄され、pendingのdisabled入力だけを隠す。
selectors: ["#activation-code:disabled", ".secret"],
text: ["synthetic-input-only"],
},
processes: [{
id: "auth-projection",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/auth-browser/fixture-server.ts",
"15392",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15392/health",
}],
};
const scenarioPath =
`${root}/target/web-ux/auth-${phase}-${colorScheme}-${width}.json`;
await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2));
const result = await capture({
scenarioPath,
outputDirectory: `${root}/target/web-ux`,
runId: `auth-${phase}-${colorScheme}-${width}`,
});
console.log(
JSON.stringify({
colorScheme,
width,
status: result.status,
captures: result.captures.length,
}),
);
}
}
@@ -0,0 +1,195 @@
// 本番build/client/shellだけを検証する合成HTTP/WebAuthn境界。
// 実認証・署名・cookie安全性・policyの永続化は検証しない。本文/credentialは記録しない。
import { extname, join, resolve } from "@std/path";
import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts";
const [port, build] = Deno.args;
if (!port || !build) throw new Error("Expected port and production build root");
const root = resolve(build);
const user = {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
};
type State = {
mode: string;
signedIn: boolean;
whoamiCalls: number;
selfRegistrationEnabled: boolean;
};
const sessions = new Map<string, State>();
const mime: Record<string, string> = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".woff2": "font/woff2",
".png": "image/png",
".ico": "image/x-icon",
};
const options = (login: boolean) => ({
challenge_id: "synthetic-challenge-only",
public_key: login ? { challenge: "dWk" } : {
challenge: "dWk",
rp: { name: "UI projection" },
user: { id: "dWk", name: "alice", displayName: "Alice" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
});
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
const url = new URL(request.url);
const path = url.pathname;
if (path === "/health") return new Response("ok");
const selected = url.searchParams.get("fixture");
let id = request.headers.get("cookie")?.match(/auth-fixture=([\w-]+)/)?.[1];
// routeごとに独立。クライアントgoto後も同じ合成sessionをwhoamiへ投影する。
if (selected || !id || !sessions.has(id)) {
id = crypto.randomUUID();
sessions.set(id, {
mode: selected ?? "anonymous",
signedIn: selected === "account" ||
selected?.startsWith("addition-") === true,
whoamiCalls: 0,
selfRegistrationEnabled: selected !== "policy-disabled",
});
}
const state = sessions.get(id)!;
const { mode } = state;
if (path === "/__ui_fixture/webauthn.js") {
let extra = "";
if (mode.endsWith("-cancel")) {
extra =
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Passkey cancelled','NotAllowedError')}});`;
} else if (mode.endsWith("-error")) {
extra =
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Synthetic authenticator failure','UnknownError')}});`;
} else if (mode.endsWith("-pending")) {
extra =
`for (const method of ['get','create']) { const original=navigator.credentials[method].bind(navigator.credentials); Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{ await new Promise(resolve=>{window.__authFixtureRelease=resolve}); return original(); }}); }`;
}
return new Response(setupWebAuthnProjection + extra, {
headers: {
"content-type": "text/javascript",
"cache-control": "no-store",
},
});
}
if (path === "/api/auth/whoami") {
state.whoamiCalls++;
if (mode === "session-loading") {
return await new Promise<Response>(() => {});
}
if (
mode === "session-error" ||
(mode === "session-retry" && state.whoamiCalls === 1)
) {
return new Response(null, { status: 503 });
}
if (mode === "session-malformed") {
return Response.json({ actor: { handle: "untrusted" } });
}
return Response.json({
actor: state.signedIn
? { ...user, auth_method: "browser_session" }
: null,
});
}
if (path === "/api/auth/registration-policy") {
if (mode === "policy-error") return new Response(null, { status: 503 });
return Response.json({
self_registration_enabled: state.selfRegistrationEnabled,
});
}
if (path === "/api/hosting/access") return Response.json({ is_admin: false });
if (path === "/api/workspaces") {
// fallbackは現行Workspace選択まで。公開トップ/T760の挙動を新設しない。
return state.signedIn
? Response.json([])
: new Response(null, { status: 401 });
}
if (path === "/api/auth/logout") {
state.signedIn = false;
return Response.json({ status: "logged_out" });
}
if (path === "/api/auth/device-login/approve") {
if (!state.signedIn) return new Response(null, { status: 401 });
if (mode === "device-expired") return new Response(null, { status: 410 });
return Response.json({ status: "approved", user });
}
const passkey = path.match(
/^\/api\/auth\/passkeys\/(login|registration|addition)\/(options|complete)$/,
);
if (passkey) {
const [, purpose, stage] = passkey;
if (purpose === "addition" && !state.signedIn) {
return new Response(null, { status: 401 });
}
if (purpose === "registration") {
if (mode === "policy-stopped" && stage === "complete") {
state.selfRegistrationEnabled = false;
}
if (!state.selfRegistrationEnabled) {
return new Response(null, { status: 403 });
}
}
if (mode.endsWith("-http-error") && stage === "options") {
return new Response(null, { status: 503 });
}
if (mode === `${purpose}-expired` && stage === "complete") {
if (purpose === "addition") state.signedIn = false;
return new Response(null, { status: 401 });
}
if (stage === "options") return Response.json(options(purpose === "login"));
state.signedIn = true;
return Response.json({ user });
}
if (path === "/api/auth/activation/options") {
return Response.json(options(false));
}
if (path === "/api/auth/activation/complete") {
state.signedIn = true;
return Response.json({ user });
}
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
let file: string;
try {
file = join(root, decodeURIComponent(path));
} catch {
return new Response(null, { status: 400 });
}
if (!file.startsWith(root + "/")) return new Response(null, { status: 400 });
let bytes: Uint8Array;
let extension = extname(file);
try {
bytes = await Deno.readFile(file);
} catch (error) {
if (
!(error instanceof Deno.errors.NotFound) &&
!(error instanceof Deno.errors.IsADirectory)
) throw error;
bytes = await Deno.readFile(join(root, "index.html"));
extension = ".html";
}
if (extension === ".html") {
bytes = new TextEncoder().encode(
new TextDecoder().decode(bytes).replace(
"<head>",
'<head><script src="/__ui_fixture/webauthn.js"></script>',
),
);
}
const headers = new Headers({
"content-type": mime[extension] ?? "application/octet-stream",
"cache-control": "no-store",
});
if (extension === ".html") {
headers.set(
"set-cookie",
`auth-fixture=${id}; Path=/; SameSite=Strict; HttpOnly`,
);
}
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
});
@@ -4,7 +4,10 @@
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium, type Page } from "playwright";
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const evidence = `${root}/target/web-ux/hosting/interactions`;
async function noOverflow(page: Page) {
@@ -63,7 +66,9 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
});
page.on("response", (response) => {
if (response.status() >= 400) {
httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`);
httpErrors.push(
`${response.status()} ${new URL(response.url()).pathname}`,
);
}
});
page.on(
@@ -80,18 +85,34 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
settingsRequests.push(request.method());
}
});
await page.goto("http://127.0.0.1:15379/account?fixture=admin-on");
await page.getByRole("button", { name: "Add passkey to alice" }).waitFor();
await page.goto("http://127.0.0.1:15379/?fixture=admin-on");
await page.getByRole("heading", { name: "Workspaces", exact: true })
.waitFor();
if (
await page.getByRole("button", { name: "Show sidebar", exact: true }).isVisible()
) await page.getByRole("button", { name: "Show sidebar", exact: true }).click();
await page.getByRole("button", {
name: "Show sidebar",
exact: true,
}).isVisible()
) {
await page.getByRole("button", {
name: "Show sidebar",
exact: true,
}).click();
}
const nav = page.getByRole("navigation", { name: "Global pages" });
await nav.getByRole("link", { name: "Hosting settings", exact: true }).waitFor();
await nav.getByRole("link", { name: "Hosting settings", exact: true })
.waitFor();
await nav.getByRole("link", { name: "Account", exact: true }).focus();
await page.keyboard.press("Tab");
assertEquals(await page.locator(":focus").innerText(), "Device Login");
assertEquals(
await page.locator(":focus").innerText(),
"Device Login",
);
await page.keyboard.press("Tab");
assertEquals(await page.locator(":focus").innerText(), "Hosting settings");
assertEquals(
await page.locator(":focus").innerText(),
"Hosting settings",
);
assertEquals(
await page.locator(":focus").evaluate((element) =>
getComputedStyle(element).outlineStyle !== "none"
@@ -99,42 +120,80 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
true,
);
await page.keyboard.press("Enter");
const edit = page.getByRole("button", { name: "Edit registration policy" });
const edit = page.getByRole("button", {
name: "Edit registration policy",
});
await edit.waitFor();
// Globalの入口からhostingへ進んだ後はWorkspace shellを離れる。
// Workspace未作成でも管理できることと整合させる。
assertEquals(await nav.count(), 0);
await page.getByRole("heading", { name: "Hosting settings", exact: true }).waitFor();
await page.getByRole("heading", {
name: "Hosting settings",
exact: true,
}).waitFor();
if (
await page.getByRole("button", { name: "Hide sidebar", exact: true }).isVisible() &&
await page.getByRole("button", {
name: "Hide sidebar",
exact: true,
}).isVisible() &&
width < 768
) await page.getByRole("button", { name: "Hide sidebar", exact: true }).click();
) {
await page.getByRole("button", {
name: "Hide sidebar",
exact: true,
}).click();
}
await noOverflow(page);
await edit.focus();
await page.keyboard.press("Enter");
const checkbox = page.getByRole("checkbox", { name: "Allow new account registration" });
const checkbox = page.getByRole("checkbox", {
name: "Allow new account registration",
});
assertEquals(
await checkbox.evaluate((element) => element === document.activeElement),
await checkbox.evaluate((element) =>
element === document.activeElement
),
true,
);
assertEquals(await checkbox.isChecked(), true);
await page.keyboard.press("Space");
await page.getByRole("button", { name: "Cancel", exact: true }).click();
assertEquals(await edit.evaluate((element) => element === document.activeElement), true);
await page.getByRole("button", { name: "Cancel", exact: true })
.click();
assertEquals(
await edit.evaluate((element) =>
element === document.activeElement
),
true,
);
await page.keyboard.press("Enter");
assertEquals(await checkbox.isChecked(), true);
await page.keyboard.press("Space");
await page.keyboard.press("Tab");
assertEquals(await page.locator(":focus").innerText(), "Save settings");
assertEquals(
await page.locator(":focus").innerText(),
"Save settings",
);
await page.keyboard.press("Enter");
await page.getByRole("status").filter({ hasText: "Hosting settings saved." }).waitFor();
assertEquals(await edit.evaluate((element) => element === document.activeElement), true);
await page.getByRole("status").filter({
hasText: "Hosting settings saved.",
}).waitFor();
assertEquals(
await edit.evaluate((element) =>
element === document.activeElement
),
true,
);
await page.getByText("Disabled", { exact: true }).waitFor();
assertEquals(settingsRequests.filter((method) => method === "PUT").length, 1);
assertEquals(
settingsRequests.filter((method) => method === "PUT").length,
1,
);
await noOverflow(page);
for (const mode of ["ordinary", "anonymous"]) {
settingsRequests.length = 0;
await page.goto(`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`);
await page.goto(
`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`,
);
await page.getByText(
mode === "ordinary"
? "Hosting administrator access is required."
@@ -144,41 +203,62 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
assertEquals(await edit.count(), 0);
assertEquals(await checkbox.count(), 0);
assertEquals(
await nav.getByRole("link", { name: "Hosting settings", exact: true }).count(),
await nav.getByRole("link", {
name: "Hosting settings",
exact: true,
}).count(),
0,
);
assertEquals(settingsRequests, []);
await noOverflow(page);
}
await page.goto("http://127.0.0.1:15379/account?fixture=account-off");
await page.goto(
"http://127.0.0.1:15379/register?fixture=account-off",
);
await page.getByText(
"New account registration is disabled. Existing users can still log in.",
).waitFor();
assertEquals(await page.getByRole("button", { name: "Register new account" }).count(), 0);
assertEquals(
await page.getByRole("button", { name: "Log in with passkey" }).isEnabled(),
true,
await page.getByRole("button", { name: "Register new account" })
.count(),
0,
);
await page.getByRole("link", {
name: "Log in with an existing account",
}).waitFor();
await noOverflow(page);
await page.goto("http://127.0.0.1:15379/account?fixture=ordinary-account-off");
await page.getByRole("button", { name: "Add passkey to alice" }).waitFor();
await page.goto(
"http://127.0.0.1:15379/account?fixture=ordinary-account-off",
);
await page.getByRole("button", { name: "Add passkey to alice" })
.waitFor();
assertEquals(
await page.getByRole("button", { name: "Add passkey to alice" }).isEnabled(),
await page.getByRole("button", { name: "Add passkey to alice" })
.isEnabled(),
true,
);
await page.goto("http://127.0.0.1:15379/hosting/settings?fixture=admin-save-error");
await page.goto(
"http://127.0.0.1:15379/hosting/settings?fixture=admin-save-error",
);
await edit.click();
await checkbox.press("Space");
await page.getByRole("button", { name: "Save settings", exact: true }).click();
await page.getByRole("button", { name: "Save settings", exact: true })
.click();
await page.getByRole("alert").waitFor();
assertEquals(await checkbox.isChecked(), false);
assertEquals(await checkbox.isDisabled(), true);
assertEquals(
await page.getByRole("button", { name: "Save settings", exact: true }).isDisabled(),
await page.getByRole("button", {
name: "Save settings",
exact: true,
}).isDisabled(),
true,
);
await noOverflow(page);
await page.getByRole("button", { name: "Reload settings", exact: true }).click();
await page.getByRole("button", {
name: "Reload settings",
exact: true,
}).click();
await edit.waitFor();
assertEquals(await page.getByRole("alert").count(), 0);
assertEquals(await edit.isEnabled(), true);
@@ -189,7 +269,11 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
"/hosting/settings",
'button:has-text("Retry loading settings")',
],
["account-error", "/account", 'button:has-text("Retry registration availability")'],
[
"account-error",
"/register",
'button:has-text("Retry registration availability")',
],
[
"admin-loading",
"/hosting/settings",
@@ -197,7 +281,7 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
],
[
"account-loading",
"/account",
"/register",
'[role="status"]:has-text("Checking registration availability")',
],
]
@@ -242,7 +326,10 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
}
}
}
await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2));
await Deno.writeTextFile(
`${evidence}/results.json`,
JSON.stringify(results, null, 2),
);
} finally {
await browser.close();
await stopOwnedProcesses(processes);
+5 -1
View File
@@ -1,10 +1,14 @@
import { assertEquals } from "jsr:@std/assert";
import { load, ssr } from "../src/routes/+layout.ts";
Deno.test("activation and hosting shells never require Workspace catalog authority", async () => {
Deno.test("authentication and hosting shells never require Workspace catalog authority", async () => {
for (
const path of [
"/activate",
"/login",
"/register",
"/account",
"/login/device",
"/hosting/accounts",
"/hosting/settings",
"/hosting/setup",