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