Merge remote-tracking branch 'origin/develop' into ticket/t-768-subject-grants
# Conflicts: # crates/worker-runtime/src/worker_backend.rs # openapi/server-api.json # web/workspace/src/lib/generated/companion-api.ts # web/workspace/src/lib/generated/repository-api.ts # web/workspace/src/lib/generated/runtime-api.ts # web/workspace/src/lib/generated/ticket-api.ts # web/workspace/src/lib/generated/worker-launch-api.ts
This commit is contained in:
@@ -19,7 +19,9 @@ export type AuthenticatedUser = { user_id: string, account_id: string, handle: s
|
||||
|
||||
export type RequestActor = { user_id: string, account_id: string, handle: string, display_name: string, auth_method: ActorAuthMethod, };
|
||||
|
||||
export type WhoamiResponse = { actor: RequestActor | null, };
|
||||
export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", };
|
||||
|
||||
export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, };
|
||||
|
||||
export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
|
||||
// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
|
||||
// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
|
||||
// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
|
||||
// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
|
||||
// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
|
||||
// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
browserSession,
|
||||
recheckAfterUnauthorized,
|
||||
} from "#lib/workspace/auth/session-state.ts";
|
||||
import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts";
|
||||
import type {
|
||||
SkillCatalogResponse,
|
||||
@@ -102,7 +106,11 @@ export async function loadJson<T>(
|
||||
): Promise<ApiResult<T>> {
|
||||
let stage: "request" | "decode" | "validate" = "request";
|
||||
try {
|
||||
const authenticationGeneration = browserSession.authenticationGeneration;
|
||||
const response = await fetchFn(path, init);
|
||||
if (response.status === 401) {
|
||||
recheckAfterUnauthorized(authenticationGeneration);
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (policy) {
|
||||
await response.body?.cancel();
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey } from './api.ts';
|
||||
import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey, logout } from './api.ts';
|
||||
import { CLEAR_RETURN_EVENT } from './session-state.ts';
|
||||
import { BrowserAuth } from './browser-session.svelte.ts';
|
||||
import { loadRegistrationPolicy } from './hosting.ts';
|
||||
import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts';
|
||||
@@ -58,11 +59,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function forget() {
|
||||
await auth.run('Logging out…', 'Logged out.', logout, 'logout');
|
||||
}
|
||||
onMount(() => {
|
||||
const clearReturn = () => { void goto(page.url.pathname, { replaceState: true }); };
|
||||
window.addEventListener(CLEAR_RETURN_EVENT, clearReturn);
|
||||
void auth.refresh();
|
||||
void refreshPolicy();
|
||||
window.addEventListener('focus', refreshPolicy);
|
||||
return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); };
|
||||
return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); window.removeEventListener(CLEAR_RETURN_EVENT, clearReturn); };
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -74,11 +80,16 @@
|
||||
{: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>
|
||||
<button type="button" disabled={auth.busy} onclick={forget}>Forget this 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 === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'}</a>
|
||||
<a href="/account">Open Account</a>
|
||||
{:else}
|
||||
{#if auth.session.session.kind === 'expired'}
|
||||
<p role="status">Your browser session expired. Log in to return to the requested page.</p>
|
||||
<button type="button" disabled={auth.busy} onclick={forget}>Forget this session</button>
|
||||
{/if}
|
||||
{#if mode === 'register'}
|
||||
{#if policyLoading}
|
||||
<p role="status" class="muted">Checking registration availability…</p>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { goto, invalidate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { browserSession, observeBrowserSession, type SessionSnapshot } from './session-state.ts';
|
||||
import { isPublicPage, loginUrl } from './routes.ts';
|
||||
import './auth-page.css';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
let session = $state<SessionSnapshot>(browserSession.snapshot);
|
||||
let admitted = $state(browserSession.snapshot.kind === 'ready' && browserSession.snapshot.value.session.kind === 'authenticated');
|
||||
const actorKey = $derived(session.kind === 'ready' && session.value.actor
|
||||
? `${session.value.actor.user_id}:${session.value.actor.account_id}` : null);
|
||||
let admittedActor = $state<string | null>(browserSession.snapshot.kind === 'ready' && browserSession.snapshot.value.actor
|
||||
? `${browserSession.snapshot.value.actor.user_id}:${browserSession.snapshot.value.actor.account_id}` : null);
|
||||
const sameActor = $derived(admittedActor === null || admittedActor === actorKey);
|
||||
const protectedPage = $derived(!isPublicPage(page.url.pathname));
|
||||
let navigating = false;
|
||||
|
||||
onMount(() => {
|
||||
observeBrowserSession();
|
||||
return browserSession.subscribe((snapshot) => {
|
||||
// 子onMountの再確認より前に入場を記録する。effect後回しだとloadingの度に
|
||||
// childを再mountし、onMount→whoami→unmountの反復を作ってしまう。
|
||||
if (snapshot.kind === 'ready' && snapshot.value.session.kind === 'authenticated') admitted = true;
|
||||
session = snapshot;
|
||||
});
|
||||
});
|
||||
$effect(() => {
|
||||
if (!protectedPage || session.kind !== 'ready') return;
|
||||
const state = session.value.session.kind;
|
||||
if (state === 'authenticated') {
|
||||
if (!sameActor) {
|
||||
// 別tabで主体が変わった場合、旧主体で取得したloader/subscriptionを再利用しない。
|
||||
if (!navigating) { navigating = true; location.reload(); }
|
||||
return;
|
||||
}
|
||||
admittedActor = actorKey;
|
||||
admitted = true;
|
||||
return;
|
||||
}
|
||||
admitted = false;
|
||||
if (navigating) return;
|
||||
navigating = true;
|
||||
const target = state === 'expired'
|
||||
? loginUrl(location.pathname + location.search + location.hash) : '/';
|
||||
void goto(target, { replaceState: true }).finally(() => { navigating = false; });
|
||||
});
|
||||
async function retry() {
|
||||
await browserSession.refresh();
|
||||
await invalidate('auth:session');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !protectedPage}
|
||||
{@render children()}
|
||||
{:else}
|
||||
{#if session.kind === 'loading'}
|
||||
<div class="auth-page"><p role="status">Loading session…</p></div>
|
||||
{:else if session.kind === 'error'}
|
||||
<div class="auth-page">
|
||||
<p class="error" role="alert">Your session could not be loaded. Login status is unknown.</p>
|
||||
<button type="button" onclick={retry}>Retry session</button>
|
||||
<a href="/">Yoi home</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted}
|
||||
<!-- 再確認中は表示/操作を停止し、進行中のlogout等をunmountで取消さない。 -->
|
||||
<div hidden={session.kind !== 'ready'} inert={session.kind !== 'ready'}>{@render children()}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -63,3 +63,12 @@ export function credentialFixture(authentication = false): Credential {
|
||||
},
|
||||
} as unknown as Credential;
|
||||
}
|
||||
|
||||
export function whoamiPayload(actor: unknown = null) {
|
||||
return {
|
||||
actor,
|
||||
session: actor === null
|
||||
? { kind: "anonymous" }
|
||||
: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { AuthRequestError, loadWhoami } from "./api.ts";
|
||||
import { AuthRequestError } from "./api.ts";
|
||||
import {
|
||||
browserSession,
|
||||
observeBrowserSession,
|
||||
sessionMutation,
|
||||
} from "./session-state.ts";
|
||||
import { notifySessionChanged } from "./hosting.ts";
|
||||
import type { RequestActor } from "./model.ts";
|
||||
import type { RequestActor, WhoamiResponse } from "./model.ts";
|
||||
|
||||
export type BrowserSession =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error" }
|
||||
| { kind: "ready"; actor: RequestActor | null };
|
||||
| ({ kind: "ready" } & WhoamiResponse);
|
||||
|
||||
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.";
|
||||
return "Authentication request was rejected. Check your session 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.";
|
||||
@@ -35,61 +40,77 @@ export class BrowserAuth {
|
||||
busy = $state(false);
|
||||
error = $state("");
|
||||
status = $state("");
|
||||
private request = 0;
|
||||
private unsubscribe: () => void;
|
||||
private disposed = false;
|
||||
private controller: AbortController | null = null;
|
||||
|
||||
constructor() {
|
||||
observeBrowserSession();
|
||||
this.unsubscribe = browserSession.subscribe((snapshot) => {
|
||||
// 操作pendingはsession確認中と別。formを消してfocus/入力を失わず、busyで再操作を止める。
|
||||
if (this.busy && snapshot.kind === "loading") return;
|
||||
if (!this.disposed) {
|
||||
this.session = snapshot.kind === "ready"
|
||||
? { kind: "ready", ...snapshot.value }
|
||||
: snapshot;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
await browserSession.refresh();
|
||||
}
|
||||
|
||||
async run(
|
||||
pending: string,
|
||||
success: string,
|
||||
operation: (signal: AbortSignal) => Promise<unknown>,
|
||||
intent: "authentication" | "logout" = "authentication",
|
||||
): Promise<boolean> {
|
||||
if (this.busy || this.disposed || this.session.kind !== "ready") {
|
||||
if (
|
||||
this.busy || this.disposed ||
|
||||
(intent !== "logout" && this.session.kind !== "ready")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.busy = true;
|
||||
++this.request;
|
||||
sessionMutation(intent);
|
||||
const authenticationGeneration = browserSession.authenticationGeneration;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
this.error = "";
|
||||
this.status = pending;
|
||||
try {
|
||||
await operation(controller.signal);
|
||||
if (this.disposed) return false;
|
||||
const superseded =
|
||||
authenticationGeneration !== browserSession.authenticationGeneration;
|
||||
sessionMutation(intent);
|
||||
const confirmationGeneration = browserSession.authenticationGeneration;
|
||||
await browserSession.refresh();
|
||||
if (
|
||||
this.disposed || superseded ||
|
||||
confirmationGeneration !== browserSession.authenticationGeneration
|
||||
) {
|
||||
if (!this.disposed) this.status = "";
|
||||
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 = "";
|
||||
if (!this.disposed) {
|
||||
this.error = authErrorMessage(error);
|
||||
this.status = "";
|
||||
}
|
||||
// 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。
|
||||
await this.readSession();
|
||||
sessionMutation(intent);
|
||||
await browserSession.refresh();
|
||||
return false;
|
||||
} finally {
|
||||
if (!this.disposed) {
|
||||
@@ -101,7 +122,7 @@ export class BrowserAuth {
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
++this.request;
|
||||
this.unsubscribe();
|
||||
this.controller?.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,10 +118,14 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
|
||||
});
|
||||
|
||||
Deno.test("whoami rejects unknown auth methods and extra fields", () => {
|
||||
assertEquals(parseWhoamiResponse({ actor: null }), { actor: null });
|
||||
assertEquals(
|
||||
parseWhoamiResponse({ actor: null, session: { kind: "anonymous" } }),
|
||||
{ actor: null, session: { kind: "anonymous" } },
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseWhoamiResponse({
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: {
|
||||
user_id: "user-1",
|
||||
account_id: "account-1",
|
||||
@@ -233,3 +237,34 @@ Deno.test("passkey credential conversion fails closed on malformed payloads", ()
|
||||
"unknown credential type",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("whoami fails closed on actor-state mismatch and malformed expiry", () => {
|
||||
const actor = {
|
||||
user_id: "u",
|
||||
account_id: "a",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
};
|
||||
for (
|
||||
const payload of [
|
||||
{ actor, session: { kind: "anonymous" } },
|
||||
{ actor, session: { kind: "expired" } },
|
||||
{
|
||||
actor: null,
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
},
|
||||
{ actor, session: { kind: "authenticated", expires_at: null } },
|
||||
{ actor, session: { kind: "authenticated", expires_at: "tomorrow" } },
|
||||
{ actor: null, session: { kind: "future" } },
|
||||
{
|
||||
actor: null,
|
||||
session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" },
|
||||
},
|
||||
]
|
||||
) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami");
|
||||
assertEquals(
|
||||
parseWhoamiResponse({ actor: null, session: { kind: "expired" } }),
|
||||
{ actor: null, session: { kind: "expired" } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -463,12 +463,40 @@ function parseActor(value: unknown, path: string): RequestActor {
|
||||
|
||||
export function parseWhoamiResponse(value: unknown): WhoamiResponse {
|
||||
const response = asRecord(value, "whoami");
|
||||
requireExactKeys(response, "whoami", ["actor"]);
|
||||
return {
|
||||
actor: response.actor === null
|
||||
requireExactKeys(response, "whoami", ["actor", "session"]);
|
||||
const actor = response.actor === null
|
||||
? null
|
||||
: parseActor(response.actor, "whoami.actor");
|
||||
const session = asRecord(response.session, "whoami.session");
|
||||
if (session.kind === "authenticated") {
|
||||
requireExactKeys(session, "whoami.session", ["kind", "expires_at"]);
|
||||
if (actor === null) {
|
||||
invalid("whoami", "authenticated session requires actor");
|
||||
}
|
||||
const expiry = session.expires_at === null
|
||||
? null
|
||||
: parseActor(response.actor, "whoami.actor"),
|
||||
};
|
||||
: boundedString(session.expires_at, "whoami.session.expires_at");
|
||||
if (
|
||||
(expiry !== null &&
|
||||
(!/^\d{4}-\d\d-\d\dT/.test(expiry) ||
|
||||
!Number.isFinite(Date.parse(expiry)))) ||
|
||||
(actor.auth_method === "browser_session" && expiry === null)
|
||||
) {
|
||||
invalid(
|
||||
"whoami.session.expires_at",
|
||||
"must identify the server session expiry",
|
||||
);
|
||||
}
|
||||
return { actor, session: { kind: "authenticated", expires_at: expiry } };
|
||||
}
|
||||
requireExactKeys(session, "whoami.session", ["kind"]);
|
||||
if (
|
||||
actor !== null ||
|
||||
(session.kind !== "anonymous" && session.kind !== "expired")
|
||||
) {
|
||||
invalid("whoami", "inconsistent actor or session state");
|
||||
}
|
||||
return { actor: null, session: { kind: session.kind } };
|
||||
}
|
||||
|
||||
export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { error, redirect } from "@sveltejs/kit";
|
||||
import { browserSession, observeBrowserSession } from "./session-state.ts";
|
||||
import { isPublicPage, loginUrl } from "./routes.ts";
|
||||
|
||||
// root loadとそのparentを待つ各loaderが同じ確認を共有する。
|
||||
// SSRは無効。fragmentはHTTP/Serverに依存せず、browserの最終URLから回収する。
|
||||
export async function requirePageSession(url: URL): Promise<void> {
|
||||
observeBrowserSession();
|
||||
if (isPublicPage(url.pathname)) return;
|
||||
const snapshot = await browserSession.refresh();
|
||||
if (snapshot !== browserSession.snapshot) return requirePageSession(url);
|
||||
if (snapshot.kind !== "ready") {
|
||||
error(
|
||||
503,
|
||||
"Your session could not be loaded. Login status is unknown. Retry this page.",
|
||||
);
|
||||
}
|
||||
if (snapshot.value.session.kind === "authenticated") return;
|
||||
if (snapshot.value.session.kind === "anonymous") redirect(307, "/");
|
||||
const target = new URL(url);
|
||||
if (
|
||||
typeof location !== "undefined" && location.pathname === url.pathname &&
|
||||
location.search === url.search
|
||||
) {
|
||||
target.hash = location.hash;
|
||||
}
|
||||
redirect(307, loginUrl(target.pathname + target.search + target.hash));
|
||||
}
|
||||
@@ -12,16 +12,16 @@ function assertEquals(
|
||||
}
|
||||
import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts";
|
||||
|
||||
Deno.test("return destination permits known app screens without forwarding arbitrary query or fragment", () => {
|
||||
Deno.test("return destination preserves query and fragment only on known app screens", () => {
|
||||
for (
|
||||
const [input, expected] of [
|
||||
["/", "/dashboard"],
|
||||
["/dashboard?query=discard#fragment", "/dashboard"],
|
||||
["/dashboard?query=keep#fragment", "/dashboard?query=keep#fragment"],
|
||||
["/account", "/account"],
|
||||
["/account?returnTo=//evil.invalid#secret", "/account"],
|
||||
[
|
||||
"/w/workspace-id/tickets/T-123?filter=all#entry",
|
||||
"/w/workspace-id/tickets/T-123",
|
||||
"/w/workspace-id/tickets/T-123?filter=all#entry",
|
||||
],
|
||||
[
|
||||
"/login/device?user_code=ABCD-EFGH",
|
||||
@@ -31,7 +31,7 @@ Deno.test("return destination permits known app screens without forwarding arbit
|
||||
"/login/device?code=abcd-1234&returnTo=//evil.invalid",
|
||||
"/login/device?user_code=abcd-1234",
|
||||
],
|
||||
["/login/device?user_code=%252f%252fevil.invalid", "/login/device"],
|
||||
["/login/device?user_code=%252f%252fevil.invalid", "/dashboard"],
|
||||
]
|
||||
) assertEquals(safeReturnTo(input), expected, input);
|
||||
});
|
||||
@@ -64,6 +64,11 @@ Deno.test("return destination rejects external, ambiguous encoding and authentic
|
||||
"/account\t",
|
||||
"/account\u0000",
|
||||
"/account%00",
|
||||
"/account?q=%255c",
|
||||
"/account?q=%0A",
|
||||
"/account#%5c",
|
||||
"/account?q=%ZZ",
|
||||
"/account?q=%252f%252fevil.invalid",
|
||||
"/api/auth/logout",
|
||||
"/hosting/setup",
|
||||
"/not-an-app-route",
|
||||
@@ -86,3 +91,38 @@ Deno.test("login links encode one validated destination and discard nested auth
|
||||
"/login?returnTo=%2Fdashboard",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("return destination accepts canonical UTF-8 resource slugs but not encoded ASCII or delimiters", () => {
|
||||
const target = `/w/team/tickets/T-123-${
|
||||
encodeURIComponent("未ログイン")
|
||||
}?view=comments#entry-2`;
|
||||
assertEquals(safeReturnTo(target), target);
|
||||
for (
|
||||
const [input, expected] of [
|
||||
[
|
||||
"/w/team/tickets?filter=100%25#entry",
|
||||
"/w/team/tickets?filter=100%25#entry",
|
||||
],
|
||||
[
|
||||
"/w/team/tickets/%e6%9c%aa?filter=100%25#part%25",
|
||||
"/w/team/tickets/%e6%9c%aa?filter=100%25#part%25",
|
||||
],
|
||||
[
|
||||
"/w/team/tickets/未?query=a%2fb#entry%2f2",
|
||||
"/w/team/tickets/%E6%9C%AA?query=a%2fb#entry%2f2",
|
||||
],
|
||||
]
|
||||
) {
|
||||
assertEquals(safeReturnTo(input), expected);
|
||||
}
|
||||
for (
|
||||
const input of [
|
||||
"/w/team/tickets/%54-123",
|
||||
"/w/team/tickets/T-123%2fother",
|
||||
"/w/team/tickets/%E0%80%AF",
|
||||
"/w/team/tickets/%252f",
|
||||
]
|
||||
) {
|
||||
assertEquals(safeReturnTo(input), "/dashboard");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,13 +11,25 @@ export function safeReturnTo(value: string | null): string {
|
||||
}
|
||||
const rawPath = value.split(/[?#]/, 1)[0];
|
||||
if (
|
||||
rawPath.includes("%") || rawPath.includes("//") ||
|
||||
/%[0-7][0-9a-f]/i.test(rawPath) || rawPath.includes("//") ||
|
||||
rawPath.split("/").some((part) => part === "." || part === "..")
|
||||
) {
|
||||
return AUTHENTICATED_HOME;
|
||||
}
|
||||
let url: URL;
|
||||
let decodedPath: string;
|
||||
try {
|
||||
decodedPath = decodeURIComponent(rawPath);
|
||||
// pathは一度だけdecodeして許可画面を判定する。UTF-8 escapeの大小や生Unicodeで
|
||||
// origin/区切りは変わらないため、再encodeとの文字列一致は求めない。
|
||||
// query/fragmentはpathの権限を持たないdata。単独の%25は保持し、次のdecodeで
|
||||
// 別文字へ変わる二重escapeとcontrol/backslashだけを拒否する。
|
||||
const decoded = decodeURIComponent(value);
|
||||
if (
|
||||
/[\\\u0000-\u001f\u007f]/u.test(decoded) || /%[0-9a-f]{2}/i.test(decoded)
|
||||
) {
|
||||
return AUTHENTICATED_HOME;
|
||||
}
|
||||
url = new URL(value, "https://app.invalid");
|
||||
} catch {
|
||||
return AUTHENTICATED_HOME;
|
||||
@@ -33,9 +45,12 @@ export function safeReturnTo(value: string | null): string {
|
||||
}
|
||||
if (
|
||||
url.pathname === AUTHENTICATED_HOME || url.pathname === "/account" ||
|
||||
/^\/w\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_/-]*)?$/.test(url.pathname)
|
||||
["/hosting/settings", "/hosting/accounts"].includes(url.pathname) ||
|
||||
/^\/w\/[a-zA-Z0-9_-]+(?:\/[\p{Letter}\p{Number}_/-]*)?$/u.test(decodedPath)
|
||||
) {
|
||||
return url.pathname;
|
||||
// 認証導線の入れ子は復帰先へ持ち込まない。検索条件とfragmentはURLとして保持する。
|
||||
if (url.searchParams.has("returnTo")) return url.pathname;
|
||||
return url.pathname + url.search + url.hash;
|
||||
}
|
||||
return AUTHENTICATED_HOME;
|
||||
}
|
||||
@@ -53,7 +68,29 @@ export function authEntryUrl(
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function isPublicPage(path: string): boolean {
|
||||
return [
|
||||
"/",
|
||||
"/login",
|
||||
"/register",
|
||||
"/activate",
|
||||
"/login/device",
|
||||
"/hosting/setup",
|
||||
]
|
||||
.includes(path);
|
||||
}
|
||||
|
||||
export function isAuthPage(path: string): boolean {
|
||||
return ["/login", "/register", "/account", "/activate", "/login/device"]
|
||||
.includes(path);
|
||||
}
|
||||
|
||||
// Universal loadのURLにfragmentがなくても同じ表示中URLならbrowserから補完する。
|
||||
export function browserUrlSuffix(url: URL): string {
|
||||
const hash =
|
||||
typeof location !== "undefined" && location.pathname === url.pathname &&
|
||||
location.search === url.search
|
||||
? location.hash
|
||||
: url.hash;
|
||||
return url.search + hash;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { createRawSnippet } from "svelte";
|
||||
import SessionBoundary from "./SessionBoundary.svelte";
|
||||
import { browserSession } from "./session-state.ts";
|
||||
import { authUser, whoamiPayload } from "./api.test-fixtures.ts";
|
||||
import { goto } from "$app/navigation";
|
||||
const page = vi.hoisted(() => ({
|
||||
url: new URL("http://localhost:3000/w/team/tickets?q=mine#entry"),
|
||||
}));
|
||||
vi.mock("$app/state", () => ({ page }));
|
||||
vi.mock(
|
||||
"$app/navigation",
|
||||
() => ({ goto: vi.fn(async () => {}), invalidate: vi.fn(async () => {}) }),
|
||||
);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
browserSession.invalidate();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
const actor = { ...authUser, auth_method: "browser_session" };
|
||||
const children = createRawSnippet(() => ({
|
||||
render: () => "<p>Private fragment</p>",
|
||||
}));
|
||||
async function admitted() {
|
||||
window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json(whoamiPayload(actor))),
|
||||
);
|
||||
await browserSession.refresh();
|
||||
render(SessionBoundary, { children });
|
||||
await screen.findByText("Private fragment");
|
||||
}
|
||||
|
||||
test("active expired state removes private content and preserves browser query and fragment", async () => {
|
||||
await admitted();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Response.json({ actor: null, session: { kind: "expired" } })
|
||||
),
|
||||
);
|
||||
await browserSession.refresh();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Private fragment")).toBeNull()
|
||||
);
|
||||
expect(goto).toHaveBeenCalledWith(
|
||||
"/login?returnTo=%2Fw%2Fteam%2Ftickets%3Fq%3Dmine%23entry",
|
||||
{ replaceState: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("unknown state hides and inerts admitted content instead of declaring anonymous", async () => {
|
||||
await admitted();
|
||||
let release!: (response: Response) => void;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
),
|
||||
);
|
||||
const checking = browserSession.refresh();
|
||||
await screen.findByText("Loading session…");
|
||||
expect(screen.getByText("Private fragment").closest("[hidden][inert]"))
|
||||
.toBeTruthy();
|
||||
release(new Response(null, { status: 503 }));
|
||||
await checking;
|
||||
await screen.findByText(
|
||||
"Your session could not be loaded. Login status is unknown.",
|
||||
);
|
||||
expect(screen.getByText("Private fragment").closest("[hidden][inert]"))
|
||||
.toBeTruthy();
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("another tab changing actor cannot reveal content loaded for the old actor", async () => {
|
||||
await admitted();
|
||||
const reload = vi.spyOn(location, "reload").mockImplementation(() => {});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Response.json(
|
||||
whoamiPayload({
|
||||
...actor,
|
||||
user_id: "different-user",
|
||||
account_id: "different-account",
|
||||
}),
|
||||
)
|
||||
),
|
||||
);
|
||||
await browserSession.refresh();
|
||||
await waitFor(() => expect(reload).toHaveBeenCalledOnce());
|
||||
expect(screen.queryByText("Private fragment")).toBeNull();
|
||||
});
|
||||
|
||||
test("a child mount session recheck does not unmount and restart its own loader", async () => {
|
||||
window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry");
|
||||
const fetcher = vi.fn(async () => Response.json(whoamiPayload(actor)));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
await browserSession.refresh();
|
||||
let mounts = 0;
|
||||
const checkingChild = createRawSnippet(() => ({
|
||||
render: () => "<p>Mounted session reader</p>",
|
||||
setup: () => {
|
||||
++mounts;
|
||||
void browserSession.refresh();
|
||||
},
|
||||
}));
|
||||
render(SessionBoundary, { children: checkingChild });
|
||||
await waitFor(() => expect(browserSession.snapshot.kind).toBe("ready"));
|
||||
expect(screen.getByText("Mounted session reader").closest("[hidden]"))
|
||||
.toBeNull();
|
||||
expect(mounts).toBe(1);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment happy-dom
|
||||
// whoami境界だけを代替し、epoch競合/再確認契機を決定的に検査する。
|
||||
// cookieと実storeの一致はServer/browser結合suiteが所有する。
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { SessionState } from "./session-state.ts";
|
||||
import type { WhoamiResponse } from "./model.ts";
|
||||
import { authUser } from "./api.test-fixtures.ts";
|
||||
|
||||
const states: SessionState[] = [];
|
||||
afterEach(() => {
|
||||
for (const state of states.splice(0)) state.dispose();
|
||||
vi.useRealTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
const anonymous: WhoamiResponse = {
|
||||
actor: null,
|
||||
session: { kind: "anonymous" },
|
||||
};
|
||||
const expired: WhoamiResponse = { actor: null, session: { kind: "expired" } };
|
||||
const authenticated = (
|
||||
expires_at: string | null = "2099-01-01T00:00:00Z",
|
||||
): WhoamiResponse => ({
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
session: { kind: "authenticated", expires_at },
|
||||
});
|
||||
function gate<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function state(read: () => Promise<WhoamiResponse>) {
|
||||
const result = new SessionState(read);
|
||||
states.push(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
test("concurrent session checks share one authoritative read without classifying loading as anonymous", async () => {
|
||||
const response = gate<WhoamiResponse>();
|
||||
const read = vi.fn(() => response.promise);
|
||||
const session = state(read);
|
||||
const first = session.refresh();
|
||||
const second = session.refresh();
|
||||
expect(session.snapshot.kind).toBe("loading");
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
response.resolve(expired);
|
||||
await Promise.all([first, second]);
|
||||
expect(session.snapshot).toEqual({ kind: "ready", value: expired });
|
||||
});
|
||||
|
||||
test("delayed expired response cannot replace a new login in the same tab", async () => {
|
||||
const older = gate<WhoamiResponse>();
|
||||
const read = vi.fn<() => Promise<WhoamiResponse>>().mockReturnValueOnce(
|
||||
older.promise,
|
||||
).mockResolvedValue(authenticated());
|
||||
const session = state(read);
|
||||
const first = session.refresh();
|
||||
session.invalidate();
|
||||
await session.refresh();
|
||||
older.resolve(expired);
|
||||
await first;
|
||||
expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() });
|
||||
});
|
||||
|
||||
test("storage generation fences responses even before the other tab storage event is delivered", async () => {
|
||||
const older = gate<WhoamiResponse>();
|
||||
const read = vi.fn<() => Promise<WhoamiResponse>>().mockReturnValueOnce(
|
||||
older.promise,
|
||||
).mockResolvedValue(authenticated());
|
||||
const session = state(read);
|
||||
const first = session.refresh();
|
||||
localStorage.setItem(
|
||||
"yoi.browser-auth-generation",
|
||||
JSON.stringify({ id: "new-login", reason: "authentication" }),
|
||||
);
|
||||
older.resolve(expired);
|
||||
await first;
|
||||
expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() });
|
||||
});
|
||||
|
||||
test("active deadline rechecks the server and reports failures as unknown, never inferred expiry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const read = vi.fn<() => Promise<WhoamiResponse>>().mockResolvedValueOnce(
|
||||
authenticated(new Date(Date.now() + 5_000).toISOString()),
|
||||
).mockRejectedValueOnce(new Error("network")).mockResolvedValue(expired);
|
||||
const session = state(read);
|
||||
await session.refresh();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(session.snapshot).toEqual({ kind: "error" });
|
||||
await session.refresh();
|
||||
expect(session.snapshot).toEqual({ kind: "ready", value: expired });
|
||||
});
|
||||
|
||||
test("a challenge failure is not session expiry; recheck may still be authenticated or anonymous", async () => {
|
||||
for (const value of [authenticated(), anonymous]) {
|
||||
const session = state(async () => value);
|
||||
session.invalidate();
|
||||
await session.refresh();
|
||||
expect(session.snapshot).toEqual({ kind: "ready", value });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/// <reference lib="dom" />
|
||||
import { loadWhoami } from "./api.ts";
|
||||
import type { WhoamiResponse } from "./model.ts";
|
||||
|
||||
export type SessionSnapshot =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error" }
|
||||
| { kind: "ready"; value: WhoamiResponse };
|
||||
|
||||
// 世代は権限ではなく、認証操作より前に開始した応答を捨てるためだけに使う。
|
||||
// Cookieの実状態は常にwhoamiが所有し、tab間通知や一般401からexpiredを推測しない。
|
||||
const GENERATION_KEY = "yoi.browser-auth-generation";
|
||||
export class SessionState {
|
||||
snapshot: SessionSnapshot = { kind: "loading" };
|
||||
private generation = 0;
|
||||
private pending: Promise<SessionSnapshot> | null = null;
|
||||
private listeners = new Set<(state: SessionSnapshot) => void>();
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
get authenticationGeneration(): number {
|
||||
return this.generation;
|
||||
}
|
||||
|
||||
constructor(private readonly read = loadWhoami) {}
|
||||
|
||||
subscribe(listener: (state: SessionSnapshot) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.snapshot);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private publish(snapshot: SessionSnapshot): void {
|
||||
this.snapshot = snapshot;
|
||||
clearTimeout(this.timer);
|
||||
if (
|
||||
snapshot.kind === "ready" &&
|
||||
snapshot.value.session.kind === "authenticated"
|
||||
) {
|
||||
const expiry = snapshot.value.session.expires_at;
|
||||
if (expiry !== null) {
|
||||
// Timerは再照会の契機。local clockだけで期限切れと断定しない。
|
||||
const remaining = Date.parse(expiry) - Date.now();
|
||||
this.timer = setTimeout(() => {
|
||||
void this.refresh();
|
||||
}, Math.min(2_147_483_647, Math.max(1_000, remaining)));
|
||||
}
|
||||
}
|
||||
for (const listener of this.listeners) listener(snapshot);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
++this.generation;
|
||||
this.pending = null;
|
||||
this.publish({ kind: "loading" });
|
||||
}
|
||||
|
||||
async refresh(): Promise<SessionSnapshot> {
|
||||
if (this.pending) return this.pending;
|
||||
const generation = this.generation;
|
||||
const stamp = storageStamp();
|
||||
this.publish({ kind: "loading" });
|
||||
const pending = (async () => {
|
||||
let result: SessionSnapshot;
|
||||
try {
|
||||
result = { kind: "ready", value: await this.read() };
|
||||
} catch {
|
||||
result = { kind: "error" };
|
||||
}
|
||||
if (generation !== this.generation || stamp !== storageStamp()) {
|
||||
// 新世代が既に確定していれば再取得すら不要。古い完了で確定結果を巻き戻さない。
|
||||
if (generation !== this.generation) {
|
||||
if (this.pending) return await this.pending;
|
||||
if (this.snapshot.kind !== "loading") return this.snapshot;
|
||||
}
|
||||
// 別tabの操作がstorage eventより先に完了していても古い応答を採用しない。
|
||||
if (generation === this.generation) this.invalidate();
|
||||
return await this.refresh();
|
||||
}
|
||||
this.pending = null;
|
||||
this.publish(result);
|
||||
return result;
|
||||
})();
|
||||
this.pending = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.timer);
|
||||
this.listeners.clear();
|
||||
++this.generation;
|
||||
}
|
||||
}
|
||||
|
||||
function storageStamp(): string | null {
|
||||
try {
|
||||
return globalThis.localStorage?.getItem(GENERATION_KEY) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const browserSession = new SessionState();
|
||||
|
||||
export function sessionMutation(
|
||||
reason: "authentication" | "logout" = "authentication",
|
||||
): void {
|
||||
browserSession.invalidate();
|
||||
const message = JSON.stringify({ id: crypto.randomUUID(), reason });
|
||||
try {
|
||||
localStorage.setItem(GENERATION_KEY, message);
|
||||
} catch { /* 保存拒否でも同tab fenceは維持。 */ }
|
||||
if (reason === "logout") clearReturnTo();
|
||||
}
|
||||
|
||||
export const CLEAR_RETURN_EVENT = "yoi:clear-auth-return";
|
||||
function clearReturnTo(): void {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event(CLEAR_RETURN_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
export function recheckAfterUnauthorized(
|
||||
authenticationGeneration: number,
|
||||
): void {
|
||||
if (
|
||||
installed &&
|
||||
authenticationGeneration === browserSession.authenticationGeneration
|
||||
) void browserSession.refresh();
|
||||
}
|
||||
export function observeBrowserSession(): void {
|
||||
if (installed || typeof window === "undefined") return;
|
||||
installed = true;
|
||||
const refresh = () => {
|
||||
void browserSession.refresh();
|
||||
};
|
||||
window.addEventListener("focus", refresh);
|
||||
window.addEventListener("pageshow", refresh);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) refresh();
|
||||
});
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key !== GENERATION_KEY && event.key !== null) return;
|
||||
browserSession.invalidate();
|
||||
try {
|
||||
if (
|
||||
event.key === null ||
|
||||
JSON.parse(event.newValue ?? "null")?.reason === "logout"
|
||||
) clearReturnTo();
|
||||
} catch { /* 不明な通知も権限ではなく再照会だけ。 */ }
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { browserSession, sessionMutation } from "./session-state.ts";
|
||||
import { BrowserAuth } from "./browser-session.svelte.ts";
|
||||
import { addPasskey, loginWithPasskey, logout } from "./api.ts";
|
||||
import { SESSION_CHANGED_EVENT } from "./hosting.ts";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
const sessions: BrowserAuth[] = [];
|
||||
afterEach(() => {
|
||||
for (const auth of sessions.splice(0)) auth.dispose();
|
||||
browserSession.invalidate();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -33,6 +35,9 @@ function session() {
|
||||
|
||||
function whoami(authenticated: boolean) {
|
||||
return Response.json({
|
||||
session: authenticated
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: authenticated
|
||||
? { ...authUser, auth_method: "browser_session" }
|
||||
: null,
|
||||
@@ -98,7 +103,11 @@ test("loading and failed whoami are not anonymous readiness and cannot start aut
|
||||
expect(await auth.run("pending", "done", operation)).toBe(false);
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
await auth.refresh();
|
||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
});
|
||||
|
||||
for (
|
||||
@@ -117,6 +126,7 @@ for (
|
||||
);
|
||||
const auth = session();
|
||||
const first = auth.refresh();
|
||||
browserSession.invalidate();
|
||||
const second = auth.refresh();
|
||||
latest.resolve(
|
||||
latestStatus === 200
|
||||
@@ -146,13 +156,21 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
|
||||
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 });
|
||||
expect(await auth.run("Logging out", "Logged out", logout, "logout")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
oldResponse.resolve(whoami(true));
|
||||
await oldRefresh;
|
||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
expect(auth.status).toBe("Logged out");
|
||||
});
|
||||
|
||||
@@ -190,7 +208,7 @@ test("busy login rejects duplicate operations and refresh until its post-complet
|
||||
options.resolve(Response.json(loginOptions));
|
||||
await confirmingStarted.promise;
|
||||
expect(auth.busy).toBe(true);
|
||||
expect(auth.session.kind).toBe("loading");
|
||||
expect(auth.session.kind).toBe("ready");
|
||||
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
|
||||
await auth.refresh();
|
||||
expect(paths(fetcher).filter((path) => path === "/api/auth/whoami"))
|
||||
@@ -220,7 +238,7 @@ for (const status of [400, 401, 410]) {
|
||||
expect(auth.status).toBe("");
|
||||
expect(auth.error).toMatch(
|
||||
status === 401
|
||||
? /Your session or authentication challenge expired/
|
||||
? /Authentication request was rejected/
|
||||
: /The request or authentication challenge is invalid or expired/,
|
||||
);
|
||||
expect(auth.session.kind).toBe("ready");
|
||||
@@ -270,7 +288,11 @@ for (
|
||||
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(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
@@ -338,7 +360,7 @@ for (const rejected of [false, true]) {
|
||||
});
|
||||
}
|
||||
|
||||
test("dispose aborts WebAuthn and ignores a late credential without completion, session notification or refresh", async () => {
|
||||
test("dispose aborts WebAuthn without accepting late credentials, while shared session is rechecked", async () => {
|
||||
const { fetcher, get } = boundary();
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
@@ -360,12 +382,17 @@ test("dispose aborts WebAuthn and ignores a late credential without completion,
|
||||
expect(signal.aborted).toBe(true);
|
||||
credential.resolve(credentialFixture(true));
|
||||
expect(await run).toBe(false);
|
||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
expect(auth.status).toBe("Waiting");
|
||||
expect(auth.error).toBe("");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
expect(
|
||||
dispatch.mock.calls.filter(([event]) =>
|
||||
@@ -409,6 +436,7 @@ test("dispose ignores a completion response even when the HTTP boundary does not
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
expect(
|
||||
dispatch.mock.calls.filter(([event]) =>
|
||||
@@ -443,15 +471,73 @@ test("dispose during the post-operation whoami prevents its late actor from beco
|
||||
(signal) => loginWithPasskey(signal),
|
||||
);
|
||||
await started.promise;
|
||||
expect(auth.session.kind).toBe("loading");
|
||||
expect(auth.session.kind).toBe("ready");
|
||||
expect(auth.busy).toBe(true);
|
||||
auth.dispose();
|
||||
response.resolve(whoami(true));
|
||||
expect(await run).toBe(false);
|
||||
expect(auth.session.kind).toBe("loading");
|
||||
expect(auth.session.kind).toBe("ready");
|
||||
expect(auth.actor).toBeNull();
|
||||
});
|
||||
|
||||
for (const newer of ["logout", "another-actor"] as const) {
|
||||
test(`confirmation superseded by ${newer} cannot report success or navigate using the old return URL`, async () => {
|
||||
const { fetcher } = boundary();
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
const confirming = gate<Response>();
|
||||
const started = gate<void>();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
started.resolve();
|
||||
return confirming.promise;
|
||||
}
|
||||
return request(input, init);
|
||||
});
|
||||
const changed = vi.fn();
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, changed);
|
||||
const navigate = vi.fn();
|
||||
try {
|
||||
const run = auth.run("Waiting", "Logged in", loginWithPasskey).then(
|
||||
(confirmed) => {
|
||||
if (confirmed && auth.actor) navigate("/account?old=return#entry");
|
||||
return confirmed;
|
||||
},
|
||||
);
|
||||
await started.promise;
|
||||
sessionMutation(newer === "logout" ? "logout" : "authentication");
|
||||
fetcher.mockResolvedValueOnce(
|
||||
newer === "logout" ? whoami(false) : Response.json({
|
||||
actor: {
|
||||
...authUser,
|
||||
user_id: "other-user",
|
||||
account_id: "other-account",
|
||||
handle: "bob",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
session: {
|
||||
kind: "authenticated",
|
||||
expires_at: "2099-01-01T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await browserSession.refresh();
|
||||
confirming.resolve(whoami(true));
|
||||
expect(await run).toBe(false);
|
||||
expect(auth.actor?.handle ?? null).toBe(
|
||||
newer === "logout" ? null : "bob",
|
||||
);
|
||||
expect(auth.status).toBe("");
|
||||
expect(auth.busy).toBe(false);
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, changed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -21,6 +21,7 @@ Deno.test("deferred load returns before API completion and snapshots navigation
|
||||
const api = gate<string>();
|
||||
const event = {
|
||||
params: { id: "first" },
|
||||
parent: async () => ({}),
|
||||
url: new URL("https://example.test/first?q=old"),
|
||||
};
|
||||
const load = deferPage(async ({ params, url }: typeof event) => ({
|
||||
@@ -54,9 +55,12 @@ Deno.test("deferred redirects and HTTP failures are handled results, not unhandl
|
||||
],
|
||||
] as const
|
||||
) {
|
||||
const load = deferPage((_event: { params: {}; url: URL }) => run());
|
||||
const load = deferPage((
|
||||
_event: { params: {}; url: URL; parent: () => Promise<unknown> },
|
||||
) => run());
|
||||
const result = await load({
|
||||
params: {},
|
||||
parent: async () => ({}),
|
||||
url: new URL("https://example.test/"),
|
||||
}).deferredPage;
|
||||
assert(
|
||||
@@ -94,3 +98,39 @@ Deno.test("resolved data preserves page overrides without waiting for the global
|
||||
result.data.workspaceId === "team",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("private deferred loader starts only after parent admission and never on parent failure", async () => {
|
||||
for (const outcome of ["admit", "anonymous", "expired", "error"] as const) {
|
||||
const admission = gate<void>();
|
||||
let calls = 0;
|
||||
const load = deferPage(
|
||||
async (
|
||||
_event: { params: {}; url: URL; parent: () => Promise<unknown> },
|
||||
) => {
|
||||
++calls;
|
||||
return {};
|
||||
},
|
||||
);
|
||||
const result = load({
|
||||
params: {},
|
||||
url: new URL("https://app.invalid/w/a/tickets"),
|
||||
parent: async () => {
|
||||
await admission.promise;
|
||||
if (outcome === "anonymous") redirect(307, "/");
|
||||
if (outcome === "expired") {
|
||||
redirect(307, "/login?returnTo=%2Fw%2Fa%2Ftickets");
|
||||
}
|
||||
if (outcome === "error") {
|
||||
error(503, "Unknown session");
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert(calls === 0);
|
||||
admission.release();
|
||||
const settled = await result.deferredPage;
|
||||
assert(calls === (outcome === "admit" ? 1 : 0));
|
||||
assert(settled.ok === (outcome === "admit"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -39,7 +39,11 @@ export async function settle<T>(
|
||||
}
|
||||
|
||||
export function deferPage<
|
||||
E extends { params: Record<string, string | undefined>; url: URL },
|
||||
E extends {
|
||||
params: Record<string, string | undefined>;
|
||||
url: URL;
|
||||
parent: () => Promise<unknown>;
|
||||
},
|
||||
T extends object,
|
||||
>(
|
||||
loader: (event: E) => T | Promise<T>,
|
||||
@@ -51,11 +55,13 @@ export function deferPage<
|
||||
params: { ...event.params },
|
||||
url: new URL(event.url.href),
|
||||
};
|
||||
try {
|
||||
return { deferredPage: settle(loader(snapshot)) };
|
||||
} catch (error) {
|
||||
return { deferredPage: settle(Promise.reject(error)) };
|
||||
}
|
||||
// SvelteKitはparent/childを並列開始する。deferしても認証前fetchを開始しない。
|
||||
return {
|
||||
deferredPage: settle((async () => {
|
||||
await snapshot.parent();
|
||||
return await loader(snapshot);
|
||||
})()),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ for (
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
session: actor
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// navigationの構成はWorkspace identityやpermissionの根拠にはしない。
|
||||
// Header下辺とSidebar右辺だけで領域境界を示し、viewport外周に線を重ねない。
|
||||
// MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。
|
||||
import SessionBoundary from '#lib/workspace/auth/SessionBoundary.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { setContext } from 'svelte';
|
||||
import { MediaQuery } from 'svelte/reactivity';
|
||||
@@ -96,6 +97,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<SessionBoundary>
|
||||
{#if standalone}
|
||||
<main class="standalone-main">
|
||||
{@render children()}
|
||||
@@ -147,6 +149,7 @@
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
</SessionBoundary>
|
||||
|
||||
<style>
|
||||
.standalone-main {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requirePageSession } from "#lib/workspace/auth/page-session.ts";
|
||||
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";
|
||||
@@ -5,28 +6,33 @@ import type { Load } from "@sveltejs/kit";
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load = ({ fetch, url }: Parameters<Load>[0]) => ({
|
||||
deferredCatalog: settle((async () => {
|
||||
// 公開入口・認証・hostingの判断はWorkspace catalogを必要としない。
|
||||
// dashboardはactor確認後に自身の一覧を取得する。
|
||||
if (
|
||||
url.pathname === "/" || url.pathname === "/dashboard" ||
|
||||
isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/")
|
||||
) {
|
||||
return { accessibleWorkspaces: [], workspaceCatalogError: null };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
accessibleWorkspaces: await listWorkspaces(fetch),
|
||||
workspaceCatalogError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
accessibleWorkspaces: [],
|
||||
workspaceCatalogError: error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load Workspaces",
|
||||
};
|
||||
}
|
||||
})()),
|
||||
});
|
||||
export const load = async ({ fetch, url, depends }: Parameters<Load>[0]) => {
|
||||
depends("auth:session");
|
||||
// href依存でquery変更を含むSPA遷移でもrootの認証gateを再実行する。
|
||||
await requirePageSession(new URL(url.href));
|
||||
return {
|
||||
deferredCatalog: settle((async () => {
|
||||
// 公開入口・認証・hostingの判断はWorkspace catalogを必要としない。
|
||||
// dashboardはactor確認後に自身の一覧を取得する。
|
||||
if (
|
||||
url.pathname === "/" || url.pathname === "/dashboard" ||
|
||||
isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/")
|
||||
) {
|
||||
return { accessibleWorkspaces: [], workspaceCatalogError: null };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
accessibleWorkspaces: await listWorkspaces(fetch),
|
||||
workspaceCatalogError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
accessibleWorkspaces: [],
|
||||
workspaceCatalogError: error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load Workspaces",
|
||||
};
|
||||
}
|
||||
})()),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
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 add() {
|
||||
if (!auth.actor) return;
|
||||
@@ -19,8 +16,8 @@
|
||||
}
|
||||
async function signOut() {
|
||||
if (!auth.actor) return;
|
||||
const confirmed = await auth.run('Logging out…', 'Logged out.', logout);
|
||||
if (confirmed && !auth.actor) await goto(loginUrl('/account'), { replaceState: true });
|
||||
const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout');
|
||||
if (confirmed && !auth.actor) await goto('/', { replaceState: true });
|
||||
}
|
||||
onMount(() => { void refresh(); return () => auth.dispose(); });
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { browserSession } from "#lib/workspace/auth/session-state.ts";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { tick } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -26,6 +27,7 @@ vi.mock("$app/state", () => ({ page }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
browserSession.invalidate();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
page.url = new URL("https://example.test/login");
|
||||
@@ -61,6 +63,9 @@ function boundary({
|
||||
return new Response(null, { status: state.whoamiStatus });
|
||||
}
|
||||
return Response.json({
|
||||
session: state.authenticated
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: state.authenticated
|
||||
? { ...authUser, auth_method: "browser_session" }
|
||||
: null,
|
||||
@@ -187,7 +192,10 @@ for (
|
||||
const [returnTo, expected] of [
|
||||
[null, "/dashboard"],
|
||||
["/account", "/account"],
|
||||
["/w/project/tickets?ignored=1#fragment", "/w/project/tickets"],
|
||||
[
|
||||
"/w/project/tickets?ignored=1#fragment",
|
||||
"/w/project/tickets?ignored=1#fragment",
|
||||
],
|
||||
[
|
||||
"/login/device?code=abcd-EFGH&untrusted=1",
|
||||
"/login/device?user_code=abcd-EFGH",
|
||||
@@ -396,14 +404,11 @@ test("account displays the current identity without editable target fields and a
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("anonymous account redirects to login with an account return path and has no credential inputs", async () => {
|
||||
test("account fragment leaves anonymous navigation to the root gate and has no credential inputs", async () => {
|
||||
const { fetcher } = boundary();
|
||||
render(Account);
|
||||
await waitFor(() =>
|
||||
expect(goto).toHaveBeenCalledWith("/login?returnTo=%2Faccount", {
|
||||
replaceState: true,
|
||||
})
|
||||
);
|
||||
await screen.findByRole("link", { name: "Log in to your account" });
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Log in to your account" }).getAttribute(
|
||||
"href",
|
||||
@@ -468,12 +473,12 @@ test("unknown account session shows retry instead of treating errors as anonymou
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("logout posts to the session endpoint and confirms anonymous whoami before login navigation", async () => {
|
||||
test("logout confirms the server session is cleared and returns to the public home", 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", {
|
||||
expect(goto).toHaveBeenCalledWith("/", {
|
||||
replaceState: true,
|
||||
})
|
||||
);
|
||||
@@ -494,7 +499,7 @@ test("expired addition revokes stale identity without silently retrying as regis
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Add passkey to alice" }),
|
||||
);
|
||||
await screen.findByText(/Your session or authentication challenge expired/);
|
||||
await screen.findByText(/Authentication request was rejected/);
|
||||
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();
|
||||
@@ -676,7 +681,7 @@ for (const status of [401, 410]) {
|
||||
);
|
||||
await screen.findByText(
|
||||
status === 401
|
||||
? /Your session or authentication challenge expired/
|
||||
? /Authentication request was rejected/
|
||||
: /The request or authentication challenge is invalid or expired/,
|
||||
);
|
||||
expect(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { browserSession } from "#lib/workspace/auth/session-state.ts";
|
||||
import Home from "./+page.svelte";
|
||||
import Dashboard from "./dashboard/+page.svelte";
|
||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
@@ -20,6 +21,7 @@ import { goto } from "$app/navigation";
|
||||
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
browserSession.invalidate();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -51,6 +53,9 @@ function boundary() {
|
||||
if (state.sessionGate) return state.sessionGate.promise;
|
||||
return state.whoamiStatus === 200
|
||||
? Response.json({
|
||||
session: state.signedIn
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: state.signedIn
|
||||
? {
|
||||
...authUser,
|
||||
@@ -288,7 +293,10 @@ test("same actor focus retains creation disclosure, repository fields, errors an
|
||||
await screen.findByText("Loading session…");
|
||||
expect(screen.queryByLabelText("Workspace display name")).toBeNull();
|
||||
state.sessionGate.resolve(
|
||||
Response.json({ actor: { ...authUser, auth_method: "browser_session" } }),
|
||||
Response.json({
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
(await screen.findByLabelText("Workspace display name") as HTMLInputElement)
|
||||
@@ -323,7 +331,10 @@ test("pending create remains single across focus and a success during unknown se
|
||||
await waitFor(() => expect(response.bodyUsed).toBe(true));
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
state.sessionGate.resolve(
|
||||
Response.json({ actor: { ...authUser, auth_method: "browser_session" } }),
|
||||
Response.json({
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
(await screen.findByRole("link", { name: "Open created Workspace" }))
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { browserSession } from "#lib/workspace/auth/session-state.ts";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import Accounts from "./+page.svelte";
|
||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
browserSession.invalidate();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
function boundary(
|
||||
@@ -20,6 +22,9 @@ function boundary(
|
||||
const fetcher = vi.fn<typeof fetch>(async (input, init) => {
|
||||
if (input === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
session: mode !== "anonymous"
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: mode === "anonymous"
|
||||
? null
|
||||
: { ...authUser, auth_method: "browser_session" },
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { browserSession } from "#lib/workspace/auth/session-state.ts";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import Settings from "./+page.svelte";
|
||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
browserSession.invalidate();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -25,6 +27,9 @@ function boundary(
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
session: persona !== "anonymous"
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: persona === "anonymous"
|
||||
? null
|
||||
: { ...authUser, auth_method: "browser_session" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { loadWhoami, loginWithPasskey } from '#lib/workspace/auth/api.ts';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { browserSession, observeBrowserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts";
|
||||
import { loginWithPasskey } from '#lib/workspace/auth/api.ts';
|
||||
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
|
||||
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
||||
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
||||
@@ -13,13 +14,26 @@
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
|
||||
let verifiedGeneration: number | null = null;
|
||||
const isCurrent = (generation: number) => active && generation === browserSession.authenticationGeneration;
|
||||
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
|
||||
async function verifySession(expected: AuthUser): Promise<boolean> {
|
||||
const { actor } = await loadWhoami();
|
||||
if (!active || actor?.user_id !== expected.user_id || actor.account_id !== expected.account_id || actor.auth_method !== 'browser_session') return false;
|
||||
const access = await loadHostingAccess();
|
||||
return active && access.is_admin;
|
||||
function matchesSession(expected: AuthUser): boolean {
|
||||
const snapshot = browserSession.snapshot;
|
||||
if (snapshot.kind !== 'ready' || snapshot.value.session.kind !== 'authenticated') return false;
|
||||
const actor = snapshot.value.actor;
|
||||
return actor?.user_id === expected.user_id && actor.account_id === expected.account_id && actor.auth_method === 'browser_session';
|
||||
}
|
||||
onMount(() => {
|
||||
observeBrowserSession();
|
||||
return browserSession.subscribe(() => {
|
||||
// 公開pageにはprivate SessionBoundaryの取消がない。成功表示も確認した世代に限る。
|
||||
// 後続の再確認/失効/主体変更では明示loginとaccess確認からやり直す。
|
||||
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || !matchesSession(user)) {
|
||||
verifiedLogin = false;
|
||||
verifiedGeneration = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function enroll() {
|
||||
if (busy || user || !token.trim()) return;
|
||||
@@ -45,17 +59,35 @@
|
||||
const expected = user;
|
||||
busy = true;
|
||||
error = '';
|
||||
sessionMutation();
|
||||
let generation = browserSession.authenticationGeneration;
|
||||
try {
|
||||
const signedIn = await loginWithPasskey(controller.signal);
|
||||
if (!active) return;
|
||||
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id || !await verifySession(expected)) {
|
||||
throw new Error('Login or hosting access not verified');
|
||||
if (!isCurrent(generation)) throw new Error('Login superseded');
|
||||
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id) {
|
||||
throw new Error('Unexpected login actor');
|
||||
}
|
||||
if (!active) return;
|
||||
// Set-Cookie後の確認を共有状態へ発行し、操作前/操作中のreadを再利用しない。
|
||||
sessionMutation();
|
||||
generation = browserSession.authenticationGeneration;
|
||||
await browserSession.refresh();
|
||||
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session not verified');
|
||||
const access = await loadHostingAccess();
|
||||
if (!isCurrent(generation) || !access.is_admin) throw new Error('Access not verified');
|
||||
// accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。
|
||||
await browserSession.refresh();
|
||||
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session superseded');
|
||||
verifiedGeneration = generation;
|
||||
verifiedLogin = true;
|
||||
notifySessionChanged();
|
||||
} catch {
|
||||
if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.';
|
||||
// 応答不明時の再確認は自身がまだ現世代の時だけ。古いfinallyで新logout/loginを
|
||||
// invalidateしたり、その確定結果を再取得のloadingへ戻したりしない。
|
||||
if (isCurrent(generation)) {
|
||||
sessionMutation();
|
||||
await browserSession.refresh();
|
||||
}
|
||||
} finally {
|
||||
if (active) busy = false;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import Setup from "./+page.svelte";
|
||||
import {
|
||||
browserSession,
|
||||
observeBrowserSession,
|
||||
} from "#lib/workspace/auth/session-state.ts";
|
||||
import { SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
@@ -17,6 +22,7 @@ import {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
browserSession.invalidate();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -34,6 +40,9 @@ function boundary(
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
session: actor
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
@@ -121,7 +130,7 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
|
||||
expect(settings.getAttribute("href")).toBe("/hosting/settings");
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
|
||||
.toHaveLength(1);
|
||||
.toHaveLength(2);
|
||||
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
|
||||
.toHaveLength(1);
|
||||
expect(
|
||||
@@ -172,6 +181,7 @@ test("a different cookie actor cannot be accepted as the enrolled administrator"
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input) === "/api/auth/whoami"
|
||||
? Response.json({
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: {
|
||||
...authUser,
|
||||
user_id: "other-user",
|
||||
@@ -248,7 +258,10 @@ test("API token identity is not mistaken for a verified browser session", async
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input) === "/api/auth/whoami"
|
||||
? Response.json({ actor: { ...authUser, auth_method: "api_token" } })
|
||||
? Response.json({
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: { ...authUser, auth_method: "api_token" },
|
||||
})
|
||||
: original(input, init)
|
||||
);
|
||||
render(Setup);
|
||||
@@ -261,3 +274,150 @@ test("API token identity is not mistaken for a verified browser session", async
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
function gate<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
const setupActor = { ...authUser, auth_method: "browser_session" };
|
||||
const replacedActor = {
|
||||
...setupActor,
|
||||
user_id: "other-user",
|
||||
account_id: "other-account",
|
||||
handle: "bob",
|
||||
};
|
||||
function identityResponse(actor: typeof setupActor | null) {
|
||||
return Response.json({
|
||||
actor,
|
||||
session: actor
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
});
|
||||
}
|
||||
async function changedInAnotherTab(actor: typeof setupActor | null) {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key: "yoi.browser-auth-generation",
|
||||
newValue: JSON.stringify({
|
||||
id: crypto.randomUUID(),
|
||||
reason: actor ? "authentication" : "logout",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await browserSession.refresh();
|
||||
}
|
||||
for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
|
||||
for (const replacement of [null, replacedActor]) {
|
||||
test(`newer ${replacement ? "actor" : "logout"} while setup ${phase} is pending cannot publish old verified success`, async () => {
|
||||
const { fetcher } = boundary();
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
const started = gate<void>();
|
||||
const pending = gate<Response>();
|
||||
let current: typeof setupActor | null = setupActor;
|
||||
let reads = 0;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
++reads;
|
||||
if (
|
||||
(phase === "whoami" && reads === 1) ||
|
||||
(phase === "confirmation" && reads === 2)
|
||||
) {
|
||||
started.resolve();
|
||||
return pending.promise;
|
||||
}
|
||||
return Promise.resolve(identityResponse(current));
|
||||
}
|
||||
if (
|
||||
(phase === "access" && path === "/api/hosting/access") ||
|
||||
(phase === "login" && path === "/api/auth/passkeys/login/complete")
|
||||
) {
|
||||
started.resolve();
|
||||
return pending.promise;
|
||||
}
|
||||
return original(input, init);
|
||||
});
|
||||
observeBrowserSession();
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await started.promise;
|
||||
const notify = vi.fn();
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, notify);
|
||||
try {
|
||||
current = replacement;
|
||||
await changedInAnotherTab(current);
|
||||
const acceptedGeneration = browserSession.authenticationGeneration;
|
||||
pending.resolve(
|
||||
phase === "access"
|
||||
? Response.json({ is_admin: true })
|
||||
: phase === "login"
|
||||
? Response.json({ user: authUser })
|
||||
: identityResponse(setupActor),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Sign in with new passkey",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false)
|
||||
);
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Passkey login and hosting administrator access verified.",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(browserSession.authenticationGeneration).toBe(
|
||||
acceptedGeneration,
|
||||
);
|
||||
expect(browserSession.snapshot).toMatchObject({
|
||||
kind: "ready",
|
||||
value: { actor: current },
|
||||
});
|
||||
} finally {
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, notify);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const replacement of [null, replacedActor]) {
|
||||
test(`setup verified success is removed on subsequent ${replacement ? "actor replacement" : "logout"}`, async () => {
|
||||
const { fetcher } = boundary();
|
||||
observeBrowserSession();
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await screen.findByRole("link", { name: "Open hosting settings" });
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) =>
|
||||
String(input) === "/api/auth/whoami"
|
||||
? Promise.resolve(identityResponse(replacement))
|
||||
: original(input, init)
|
||||
);
|
||||
await changedInAnotherTab(replacement);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull()
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Passkey login and hosting administrator access verified.",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Sign in with new passkey",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,34 +4,62 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { createRawSnippet, tick } from "svelte";
|
||||
import { logout } from "#lib/workspace/auth/api.ts";
|
||||
import { clearWorkspaceAlerts, pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
|
||||
import { page } from '$app/state';
|
||||
import { settle } from '#lib/workspace/navigation/deferred.ts';
|
||||
import {
|
||||
clearWorkspaceAlerts,
|
||||
pushWorkspaceAlert,
|
||||
} from "#lib/workspace/alerts/store.ts";
|
||||
import { page } from "$app/state";
|
||||
import { settle } from "#lib/workspace/navigation/deferred.ts";
|
||||
import { browserSession } from "#lib/workspace/auth/session-state.ts";
|
||||
import {
|
||||
authUser,
|
||||
whoamiPayload,
|
||||
} from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
import Layout from "./+layout.svelte";
|
||||
|
||||
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/w/example") } }));
|
||||
vi.mock(
|
||||
"$app/state",
|
||||
() => ({ page: { url: new URL("https://example.test/w/example") } }),
|
||||
);
|
||||
const storageKey = "yoi.sidebar.mode.v1";
|
||||
const legacyKey = "yoi.sidebar.folded.v1";
|
||||
const props = {
|
||||
params: {},
|
||||
children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })),
|
||||
data: { deferredCatalog: settle({ accessibleWorkspaces: [], workspaceCatalogError: null }) },
|
||||
data: {
|
||||
deferredCatalog: settle({
|
||||
accessibleWorkspaces: [],
|
||||
workspaceCatalogError: null,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const frame = () => screen.getByRole("complementary", { name: "Sidebar" });
|
||||
const folded = () => frame().classList.contains("folded");
|
||||
const hoverRegion = () => frame().querySelector<HTMLElement>(".sidebar-hover-region")!;
|
||||
const enter = () => fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse" });
|
||||
const leave = () => fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" });
|
||||
const hoverRegion = () =>
|
||||
frame().querySelector<HTMLElement>(".sidebar-hover-region")!;
|
||||
const enter = () =>
|
||||
fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse" });
|
||||
const leave = () =>
|
||||
fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" });
|
||||
const delay = () => vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
clearWorkspaceAlerts();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input) => {
|
||||
if (String(input) === '/api/auth/whoami') return Response.json({ actor: null });
|
||||
throw new Error(`Unexpected request ${input}`);
|
||||
}));
|
||||
page.url = new URL('https://example.test/w/example');
|
||||
browserSession.snapshot = {
|
||||
kind: "ready",
|
||||
value: whoamiPayload({ ...authUser, auth_method: "browser_session" }),
|
||||
} as typeof browserSession.snapshot;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
return Response.json({ session: { kind: "anonymous" }, actor: null });
|
||||
}
|
||||
throw new Error(`Unexpected request ${input}`);
|
||||
}),
|
||||
);
|
||||
page.url = new URL("https://example.test/w/example");
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -42,22 +70,36 @@ afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test.each(['/', '/dashboard', '/login', '/register', '/account', '/hosting/settings'])(
|
||||
'standalone %s never displays retained private Workspace notifications',
|
||||
test.each([
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/login",
|
||||
"/register",
|
||||
"/account",
|
||||
"/hosting/settings",
|
||||
])(
|
||||
"standalone %s never displays retained private Workspace notifications",
|
||||
(path) => {
|
||||
pushWorkspaceAlert('error', 'Private Workspace Orion: worker deletion failed');
|
||||
page.url = new URL(path, 'https://example.test');
|
||||
pushWorkspaceAlert(
|
||||
"error",
|
||||
"Private Workspace Orion: worker deletion failed",
|
||||
);
|
||||
page.url = new URL(path, "https://example.test");
|
||||
render(Layout, { props });
|
||||
expect(screen.getByText('Page content')).toBeTruthy();
|
||||
expect(screen.queryByText('Private Workspace Orion: worker deletion failed')).toBeNull();
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
expect(screen.getByText("Page content")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText("Private Workspace Orion: worker deletion failed"),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test('Workspace shell continues displaying Workspace action notifications', () => {
|
||||
pushWorkspaceAlert('error', 'Worker deletion failed');
|
||||
test("Workspace shell continues displaying Workspace action notifications", () => {
|
||||
pushWorkspaceAlert("error", "Worker deletion failed");
|
||||
render(Layout, { props });
|
||||
expect(screen.getByRole('alert').textContent).toContain('Worker deletion failed');
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"Worker deletion failed",
|
||||
);
|
||||
});
|
||||
|
||||
test("Workspace catalog failure uses the shared alert without rendering diagnostics in the sidebar", async () => {
|
||||
@@ -65,7 +107,10 @@ test("Workspace catalog failure uses the shared alert without rendering diagnost
|
||||
props: {
|
||||
...props,
|
||||
data: {
|
||||
deferredCatalog: settle({ accessibleWorkspaces: [], workspaceCatalogError: "Workspace catalog request failed" }),
|
||||
deferredCatalog: settle({
|
||||
accessibleWorkspaces: [],
|
||||
workspaceCatalogError: "Workspace catalog request failed",
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -78,10 +123,16 @@ test("Workspace catalog failure uses the shared alert without rendering diagnost
|
||||
|
||||
test("unpinning changes mode, and pointer departure closes the preview", async () => {
|
||||
render(Layout, { props });
|
||||
expect(screen.getByRole("button", { name: "Unpin sidebar" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Unpin sidebar" }).getAttribute(
|
||||
"aria-pressed",
|
||||
),
|
||||
).toBe("true");
|
||||
await enter();
|
||||
screen.getByRole("button", { name: "Unpin sidebar" }).focus();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), {
|
||||
detail: 1,
|
||||
});
|
||||
expect(localStorage.getItem(storageKey)).toBe("hover");
|
||||
expect(folded()).toBe(false);
|
||||
await leave();
|
||||
@@ -107,7 +158,9 @@ test("footer pointer clicks release previous sidebar link focus before unpinning
|
||||
render(Layout, { props });
|
||||
frame().querySelector<HTMLAnchorElement>("a")!.focus();
|
||||
await tick();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), {
|
||||
detail: 1,
|
||||
});
|
||||
expect(folded()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -121,12 +174,15 @@ test("keyboard mode switching preserves the focused preview", async () => {
|
||||
expect(folded()).toBe(false);
|
||||
});
|
||||
|
||||
test.each(["pinned", "hover"])("restores %s mode without persisting a transient open state", (mode) => {
|
||||
localStorage.setItem(storageKey, mode);
|
||||
render(Layout, { props });
|
||||
expect(folded()).toBe(mode === "hover");
|
||||
expect(localStorage.getItem(storageKey)).toBe(mode);
|
||||
});
|
||||
test.each(["pinned", "hover"])(
|
||||
"restores %s mode without persisting a transient open state",
|
||||
(mode) => {
|
||||
localStorage.setItem(storageKey, mode);
|
||||
render(Layout, { props });
|
||||
expect(folded()).toBe(mode === "hover");
|
||||
expect(localStorage.getItem(storageKey)).toBe(mode);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([true, false])("migrates legacy folded=%s to a mode", (value) => {
|
||||
localStorage.setItem(legacyKey, String(value));
|
||||
@@ -171,7 +227,8 @@ test("footer hover does not open or hold the preview, but its button still chang
|
||||
render(Layout, { props });
|
||||
const footer = frame().querySelector<HTMLElement>(".sidebar-control-row")!;
|
||||
const button = screen.getByRole("button", { name: "Pin sidebar" });
|
||||
expect(button.querySelector('[data-icon="panel-right-open"] rect')).not.toBeNull();
|
||||
expect(button.querySelector('[data-icon="panel-right-open"] rect')).not
|
||||
.toBeNull();
|
||||
await fireEvent.pointerEnter(footer, { pointerType: "mouse" });
|
||||
await fireEvent.pointerEnter(button, { pointerType: "mouse" });
|
||||
await delay();
|
||||
@@ -184,7 +241,8 @@ test("footer hover does not open or hold the preview, but its button still chang
|
||||
expect(folded()).toBe(true);
|
||||
await fireEvent.click(button, { detail: 1 });
|
||||
expect(folded()).toBe(false);
|
||||
expect(button.querySelector('[data-icon="panel-right-close"] rect')).not.toBeNull();
|
||||
expect(button.querySelector('[data-icon="panel-right-close"] rect')).not
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("keyboard focus opens the preview and holds it until focus leaves", async () => {
|
||||
@@ -206,7 +264,9 @@ test("pinning a preview keeps it open after pointer and focus leave", async () =
|
||||
localStorage.setItem(storageKey, "hover");
|
||||
render(Layout, { props });
|
||||
await enter();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }), { detail: 1 });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }), {
|
||||
detail: 1,
|
||||
});
|
||||
await leave();
|
||||
await delay();
|
||||
expect(folded()).toBe(false);
|
||||
@@ -214,7 +274,10 @@ test("pinning a preview keeps it open after pointer and focus leave", async () =
|
||||
});
|
||||
|
||||
test("mode survives logout and remount, but an open preview does not", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" })));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json({ status: "logged_out" })),
|
||||
);
|
||||
localStorage.setItem(storageKey, "hover");
|
||||
const view = render(Layout, { props });
|
||||
await enter();
|
||||
@@ -229,7 +292,9 @@ test("touch does not hover; mobile header opens temporarily without changing mod
|
||||
const matchMedia = window.matchMedia.bind(window);
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query) => {
|
||||
const result = matchMedia(query);
|
||||
Object.defineProperty(result, "matches", { value: query === "(max-width: 760px)" });
|
||||
Object.defineProperty(result, "matches", {
|
||||
value: query === "(max-width: 760px)",
|
||||
});
|
||||
return result;
|
||||
});
|
||||
localStorage.setItem(storageKey, "hover");
|
||||
@@ -255,7 +320,9 @@ test("invalid storage falls back to pinned", () => {
|
||||
});
|
||||
|
||||
test("unavailable storage does not prevent mode switching", async () => {
|
||||
vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); });
|
||||
vi.spyOn(window, "localStorage", "get").mockImplementation(() => {
|
||||
throw new DOMException("Blocked", "SecurityError");
|
||||
});
|
||||
render(Layout, { props });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }));
|
||||
await delay();
|
||||
@@ -266,7 +333,9 @@ test("unavailable storage does not prevent mode switching", async () => {
|
||||
|
||||
test("full storage still restores the mode and allows changes", async () => {
|
||||
localStorage.setItem(storageKey, "hover");
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); });
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new DOMException("Full", "QuotaExceededError");
|
||||
});
|
||||
render(Layout, { props });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }));
|
||||
expect(folded()).toBe(false);
|
||||
|
||||
@@ -5,12 +5,18 @@ import { parseWorkspaceResponse } from "#lib/workspace/api/workspace-model.ts";
|
||||
import { settle } from "#lib/workspace/navigation/deferred.ts";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
|
||||
export const load = ({ fetch, params }: Parameters<LayoutLoad>[0]) => {
|
||||
export const load = async (
|
||||
{ fetch, params, parent }: Parameters<LayoutLoad>[0],
|
||||
) => {
|
||||
await parent();
|
||||
const workspaceId = params.workspaceId;
|
||||
return { deferredWorkspace: settle(loadWorkspace(fetch, workspaceId)) };
|
||||
};
|
||||
|
||||
async function loadWorkspace(fetch: Parameters<LayoutLoad>[0]["fetch"], workspaceId: string) {
|
||||
async function loadWorkspace(
|
||||
fetch: Parameters<LayoutLoad>[0]["fetch"],
|
||||
workspaceId: string,
|
||||
) {
|
||||
const [workspaceResult, repositoryResult] = await Promise.all([
|
||||
loadJson(
|
||||
fetch,
|
||||
@@ -43,4 +49,4 @@ async function loadWorkspace(fetch: Parameters<LayoutLoad>[0]["fetch"], workspac
|
||||
repositories,
|
||||
repositoriesError,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { loadDashboard } from "#lib/workspace/home/dashboard.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = ({ fetch, params, depends }) => {
|
||||
export const load: PageLoad = async ({ fetch, params, depends, parent }) => {
|
||||
await parent();
|
||||
depends("workspace:home");
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
|
||||
@@ -79,6 +79,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl
|
||||
const depends = vi.fn();
|
||||
const result = await load(
|
||||
{
|
||||
parent: async () => ({}),
|
||||
fetch,
|
||||
params: { workspaceId: "home-owner" },
|
||||
depends,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import {
|
||||
@@ -11,36 +12,39 @@ import {
|
||||
import type { PageLoad } from "./$types";
|
||||
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
|
||||
|
||||
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
|
||||
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
||||
const objectiveId = resourceKey(params.objectiveId);
|
||||
const objective = await loadJson(
|
||||
fetch,
|
||||
apiPath(`/objectives/${encodeURIComponent(objectiveId)}`),
|
||||
undefined,
|
||||
parseObjectiveDetail,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
);
|
||||
|
||||
if (objective.data) {
|
||||
const canonical = canonicalResourceReference(
|
||||
objective.data.resource_key,
|
||||
objective.data.title,
|
||||
export const load = deferPage(
|
||||
async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
|
||||
const apiPath = (path: string) =>
|
||||
workspaceApiPath(params.workspaceId, path);
|
||||
const objectiveId = resourceKey(params.objectiveId);
|
||||
const objective = await loadJson(
|
||||
fetch,
|
||||
apiPath(`/objectives/${encodeURIComponent(objectiveId)}`),
|
||||
undefined,
|
||||
parseObjectiveDetail,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
);
|
||||
if (params.objectiveId !== canonical) {
|
||||
redirect(
|
||||
308,
|
||||
`/w/${encodeURIComponent(params.workspaceId)}/objectives/${
|
||||
encodeURIComponent(canonical)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
objectiveId,
|
||||
objective: objective.data,
|
||||
objectiveError: objective.error,
|
||||
};
|
||||
});
|
||||
if (objective.data) {
|
||||
const canonical = canonicalResourceReference(
|
||||
objective.data.resource_key,
|
||||
objective.data.title,
|
||||
);
|
||||
if (params.objectiveId !== canonical) {
|
||||
redirect(
|
||||
308,
|
||||
`/w/${encodeURIComponent(params.workspaceId)}/objectives/${
|
||||
encodeURIComponent(canonical)
|
||||
}${browserUrlSuffix(url)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
objectiveId,
|
||||
objective: objective.data,
|
||||
objectiveError: objective.error,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
|
||||
import { error, redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
|
||||
@@ -14,57 +15,62 @@ import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-
|
||||
import type { PageLoad } from "./$types";
|
||||
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
|
||||
|
||||
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
|
||||
const reference = resourceKey(params.ticketId);
|
||||
const ticketPath = workspaceApiPath(
|
||||
params.workspaceId,
|
||||
`/tickets/${encodeURIComponent(reference)}`,
|
||||
);
|
||||
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
|
||||
loadJson(
|
||||
fetch,
|
||||
ticketPath,
|
||||
undefined,
|
||||
parseTicketDetail,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
),
|
||||
loadWorkspaceRepositoryList(fetch, params.workspaceId),
|
||||
loadJson<WorkspaceOrchestratorStatus>(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
||||
undefined,
|
||||
parseBrowserWorkspaceOrchestratorResponse,
|
||||
),
|
||||
]);
|
||||
if (!ticket.data) {
|
||||
const message = ticket.error ?? "Ticket details could not be loaded";
|
||||
if (ticket.status !== undefined && ticket.status >= 400 && ticket.status <= 599) {
|
||||
error(ticket.status, message);
|
||||
}
|
||||
// Expected read failures belong to the deferred loader, not component construction.
|
||||
throw new Error(message);
|
||||
}
|
||||
if (ticket.data) {
|
||||
const canonical = canonicalResourceReference(
|
||||
ticket.data.resource_key,
|
||||
ticket.data.title,
|
||||
export const load = deferPage(
|
||||
async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
|
||||
const reference = resourceKey(params.ticketId);
|
||||
const ticketPath = workspaceApiPath(
|
||||
params.workspaceId,
|
||||
`/tickets/${encodeURIComponent(reference)}`,
|
||||
);
|
||||
if (params.ticketId !== canonical) {
|
||||
redirect(
|
||||
308,
|
||||
`/w/${encodeURIComponent(params.workspaceId)}/tickets/${
|
||||
encodeURIComponent(canonical)
|
||||
}`,
|
||||
);
|
||||
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
|
||||
loadJson(
|
||||
fetch,
|
||||
ticketPath,
|
||||
undefined,
|
||||
parseTicketDetail,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
),
|
||||
loadWorkspaceRepositoryList(fetch, params.workspaceId),
|
||||
loadJson<WorkspaceOrchestratorStatus>(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
||||
undefined,
|
||||
parseBrowserWorkspaceOrchestratorResponse,
|
||||
),
|
||||
]);
|
||||
if (!ticket.data) {
|
||||
const message = ticket.error ?? "Ticket details could not be loaded";
|
||||
if (
|
||||
ticket.status !== undefined && ticket.status >= 400 &&
|
||||
ticket.status <= 599
|
||||
) {
|
||||
error(ticket.status, message);
|
||||
}
|
||||
// Expected read failures belong to the deferred loader, not component construction.
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
const repositories = repositoriesRaw;
|
||||
if (ticket.data) {
|
||||
const canonical = canonicalResourceReference(
|
||||
ticket.data.resource_key,
|
||||
ticket.data.title,
|
||||
);
|
||||
if (params.ticketId !== canonical) {
|
||||
redirect(
|
||||
308,
|
||||
`/w/${encodeURIComponent(params.workspaceId)}/tickets/${
|
||||
encodeURIComponent(canonical)
|
||||
}${browserUrlSuffix(url)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const repositories = repositoriesRaw;
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
ticketId: ticket.data?.id ?? reference,
|
||||
ticket,
|
||||
repositories,
|
||||
orchestrator,
|
||||
};
|
||||
});
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
ticketId: ticket.data?.id ?? reference,
|
||||
ticket,
|
||||
repositories,
|
||||
orchestrator,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -108,6 +108,7 @@ async function loadPage(
|
||||
) {
|
||||
const result = await load(
|
||||
{
|
||||
parent: async () => ({}),
|
||||
fetch: fetchFn,
|
||||
params: { workspaceId },
|
||||
url: new URL(
|
||||
@@ -141,7 +142,7 @@ test("actual loader posts browse only; rows render while orchestrator is unresol
|
||||
"/api/w/workspace-a/repositories": () => repos.promise,
|
||||
});
|
||||
const pending = loadPage(request);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
||||
expect(request.mock.calls[0][0]).toBe("/api/w/workspace-a/tickets/browse");
|
||||
expect(request.mock.calls[0][1]?.method).toBe("POST");
|
||||
expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import { parseWorkerSummary } from "#lib/workspace/api/workers.ts";
|
||||
@@ -8,33 +9,37 @@ import {
|
||||
import type { PageLoad } from "./$types";
|
||||
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
|
||||
|
||||
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
|
||||
const reference = resourceKey(params.workerRef);
|
||||
const result = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
params.workspaceId,
|
||||
`/workers/${encodeURIComponent(reference)}`,
|
||||
),
|
||||
undefined,
|
||||
parseWorkerSummary,
|
||||
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
|
||||
);
|
||||
if (result.data?.resource_key) {
|
||||
const canonical = canonicalResourceReference(
|
||||
result.data.resource_key,
|
||||
result.data.display_name,
|
||||
export const load = deferPage(
|
||||
async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
|
||||
const reference = resourceKey(params.workerRef);
|
||||
const result = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
params.workspaceId,
|
||||
`/workers/${encodeURIComponent(reference)}`,
|
||||
),
|
||||
undefined,
|
||||
parseWorkerSummary,
|
||||
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
|
||||
);
|
||||
if (params.workerRef !== canonical) {
|
||||
redirect(
|
||||
308,
|
||||
`/w/${encodeURIComponent(params.workspaceId)}/workers/${encodeURIComponent(canonical)}`,
|
||||
if (result.data?.resource_key) {
|
||||
const canonical = canonicalResourceReference(
|
||||
result.data.resource_key,
|
||||
result.data.display_name,
|
||||
);
|
||||
if (params.workerRef !== canonical) {
|
||||
redirect(
|
||||
308,
|
||||
`/w/${encodeURIComponent(params.workspaceId)}/workers/${
|
||||
encodeURIComponent(canonical)
|
||||
}${browserUrlSuffix(url)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
worker: result.data,
|
||||
workerError: result.error,
|
||||
};
|
||||
});
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
worker: result.data,
|
||||
workerError: result.error,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import { parseWorkerSummary } from "#lib/workspace/api/workers.ts";
|
||||
@@ -8,38 +9,40 @@ import {
|
||||
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
|
||||
const reference = resourceKey(params.workerRef);
|
||||
const result = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
params.workspaceId,
|
||||
`/workers/${encodeURIComponent(reference)}`,
|
||||
),
|
||||
undefined,
|
||||
parseWorkerSummary,
|
||||
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
|
||||
);
|
||||
if (result.data?.resource_key) {
|
||||
const canonical = canonicalResourceReference(
|
||||
result.data.resource_key,
|
||||
result.data.display_name,
|
||||
export const load = deferPage(
|
||||
async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
|
||||
const reference = resourceKey(params.workerRef);
|
||||
const result = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
params.workspaceId,
|
||||
`/workers/${encodeURIComponent(reference)}`,
|
||||
),
|
||||
undefined,
|
||||
parseWorkerSummary,
|
||||
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
|
||||
);
|
||||
if (params.workerRef !== canonical) {
|
||||
redirect(
|
||||
308,
|
||||
`/w/${encodeURIComponent(params.workspaceId)}/workers/${
|
||||
encodeURIComponent(canonical)
|
||||
}/console`,
|
||||
if (result.data?.resource_key) {
|
||||
const canonical = canonicalResourceReference(
|
||||
result.data.resource_key,
|
||||
result.data.display_name,
|
||||
);
|
||||
if (params.workerRef !== canonical) {
|
||||
redirect(
|
||||
308,
|
||||
`/w/${encodeURIComponent(params.workspaceId)}/workers/${
|
||||
encodeURIComponent(canonical)
|
||||
}/console${browserUrlSuffix(url)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
runtimeId: result.data?.runtime_id ?? null,
|
||||
workerId: result.data?.worker_id ?? null,
|
||||
worker: result.data,
|
||||
workerError: result.error,
|
||||
};
|
||||
});
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
runtimeId: result.data?.runtime_id ?? null,
|
||||
workerId: result.data?.worker_id ?? null,
|
||||
worker: result.data,
|
||||
workerError: result.error,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,17 +5,29 @@ import WorkspaceError from "./+error.svelte";
|
||||
import { load } from "./+layout.ts";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
page: { params: { workspaceId: "workspace-a" }, status: 500, error: { message: "Ticket Browser API response is invalid" } },
|
||||
page: {
|
||||
params: { workspaceId: "workspace-a" },
|
||||
status: 500,
|
||||
error: { message: "Ticket Browser API response is invalid" },
|
||||
},
|
||||
}));
|
||||
vi.mock("$app/state", () => state);
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("a child page error preserves its message and never claims Workspace deletion", async () => {
|
||||
const reload = vi.spyOn(window.location, "reload").mockImplementation(() => {});
|
||||
const reload = vi.spyOn(window.location, "reload").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
const view = render(WorkspaceError);
|
||||
expect(view.getByRole("alert").textContent).toBe("Ticket Browser API response is invalid");
|
||||
expect(view.getByRole("alert").textContent).toBe(
|
||||
"Ticket Browser API response is invalid",
|
||||
);
|
||||
expect(view.getByText("Error 500")).toBeTruthy();
|
||||
expect(view.queryByText("The selected Workspace cannot be opened")).toBeNull();
|
||||
expect(view.queryByText("The selected Workspace cannot be opened"))
|
||||
.toBeNull();
|
||||
expect(view.queryByText("Workspace unavailable")).toBeNull();
|
||||
expect(view.queryByText("Create Workspace")).toBeNull();
|
||||
expect(view.queryByText("Choose another Workspace")).toBeNull();
|
||||
@@ -28,31 +40,49 @@ async function loadFailure(workspaceFetch: () => Promise<Response>) {
|
||||
if (String(input).endsWith("/workspace")) return await workspaceFetch();
|
||||
return new Response("repository lookup unavailable", { status: 503 });
|
||||
});
|
||||
return await load({ fetch: fetcher, params: { workspaceId: "workspace-a" } } as unknown as Parameters<typeof load>[0]).deferredWorkspace;
|
||||
return await (await load(
|
||||
{
|
||||
fetch: fetcher,
|
||||
parent: async () => ({}),
|
||||
params: { workspaceId: "workspace-a" },
|
||||
} as unknown as Parameters<typeof load>[0],
|
||||
)).deferredWorkspace;
|
||||
}
|
||||
|
||||
test.each([401, 403, 404, 500, 503])("Workspace HTTP %i is not rewritten as 404", async (status) => {
|
||||
const result = await loadFailure(async () => new Response("private backend body", { status }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected a failed Workspace load");
|
||||
expect(result.failure.status).toBe(status);
|
||||
expect(result.failure.message).toBe(`Workspace API request failed with HTTP ${status}`);
|
||||
expect(result.failure.message).not.toContain("private backend body");
|
||||
});
|
||||
test.each([401, 403, 404, 500, 503])(
|
||||
"Workspace HTTP %i is not rewritten as 404",
|
||||
async (status) => {
|
||||
const result = await loadFailure(async () =>
|
||||
new Response("private backend body", { status })
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected a failed Workspace load");
|
||||
expect(result.failure.status).toBe(status);
|
||||
expect(result.failure.message).toBe(
|
||||
`Workspace API request failed with HTTP ${status}`,
|
||||
);
|
||||
expect(result.failure.message).not.toContain("private backend body");
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["network", "json", "contract"] as const)("Workspace %s failures do not imply a missing Workspace", async (kind) => {
|
||||
const result = await loadFailure(async () => {
|
||||
if (kind === "network") throw new TypeError("Failed to fetch");
|
||||
return kind === "json" ? new Response("not json") : Response.json({});
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected a failed Workspace load");
|
||||
expect(result.failure.status).toBeNull();
|
||||
expect(result.failure.message).toBe({
|
||||
network: "Workspace API request could not be completed",
|
||||
json: "Workspace API response could not be decoded as JSON",
|
||||
contract: "Workspace API response is invalid",
|
||||
}[kind]);
|
||||
expect(result.failure.message).not.toContain("removed");
|
||||
expect(result.failure.message).not.toContain("404");
|
||||
});
|
||||
test.each(["network", "json", "contract"] as const)(
|
||||
"Workspace %s failures do not imply a missing Workspace",
|
||||
async (kind) => {
|
||||
const result = await loadFailure(async () => {
|
||||
if (kind === "network") throw new TypeError("Failed to fetch");
|
||||
return kind === "json" ? new Response("not json") : Response.json({});
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected a failed Workspace load");
|
||||
expect(result.failure.status).toBeNull();
|
||||
expect(result.failure.message).toBe(
|
||||
{
|
||||
network: "Workspace API request could not be completed",
|
||||
json: "Workspace API response could not be decoded as JSON",
|
||||
contract: "Workspace API response is invalid",
|
||||
}[kind],
|
||||
);
|
||||
expect(result.failure.message).not.toContain("removed");
|
||||
expect(result.failure.message).not.toContain("404");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -143,11 +143,13 @@ Deno.test("activation standalone shell supports issuance, reissue, denial, resta
|
||||
path.startsWith("POST ")
|
||||
).length;
|
||||
await go("/hosting/accounts", mode);
|
||||
await page.getByText(
|
||||
mode === "anonymous"
|
||||
? "Sign in with a hosting administrator account to issue activation codes."
|
||||
: "Hosting administrator access is required.",
|
||||
).waitFor();
|
||||
if (mode === "anonymous") {
|
||||
await page.waitForURL("http://127.0.0.1:15381/");
|
||||
await page.getByRole("heading", { name: "Yoi", exact: true })
|
||||
.waitFor();
|
||||
} else {await page.getByText(
|
||||
"Hosting administrator access is required.",
|
||||
).waitFor();}
|
||||
assertEquals(
|
||||
await page.getByLabel("Account ID (handle)").count(),
|
||||
0,
|
||||
|
||||
@@ -41,6 +41,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
if (path === "/api/workspaces") return new Response(null, { status: 503 });
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
session: mode !== "anonymous"
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: mode === "anonymous"
|
||||
? null
|
||||
: { ...user, auth_method: "browser_session" },
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { type Browser, chromium, type Page } from "playwright";
|
||||
import { sessionNavigation } from "./session-navigation.ts";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
@@ -14,7 +15,7 @@ type Mutation = { path: string; keys: string[] };
|
||||
type Harness = {
|
||||
page: Page;
|
||||
mutations: Mutation[];
|
||||
go: (path: string, mode?: string) => Promise<void>;
|
||||
go: (path: string, mode?: string, unavailableRoot?: boolean) => Promise<void>;
|
||||
};
|
||||
const login = async (page: Page) => {
|
||||
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||
@@ -67,6 +68,19 @@ async function isolated(
|
||||
const page = await context.newPage();
|
||||
const mutations: Mutation[] = [];
|
||||
const errors: string[] = [];
|
||||
const rejectedLoads: unknown[] = [];
|
||||
let expectsUnavailableRoot = false;
|
||||
await page.exposeFunction(
|
||||
"__authFixtureRejectedLoad",
|
||||
(reason: unknown) => rejectedLoads.push(reason),
|
||||
);
|
||||
await page.addInitScript(() => {
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
void (window as unknown as {
|
||||
__authFixtureRejectedLoad: (reason: unknown) => Promise<void>;
|
||||
}).__authFixtureRejectedLoad(event.reason);
|
||||
});
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
@@ -81,14 +95,38 @@ async function isolated(
|
||||
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);
|
||||
go: async (path, mode = "anonymous", unavailableRoot = false) => {
|
||||
// fixture選択を実際の戻り先queryへ混入させない。同じcontextのcookieをHTTPで設定する。
|
||||
expectsUnavailableRoot ||= unavailableRoot ||
|
||||
(path.startsWith("/account") &&
|
||||
[
|
||||
"session-error",
|
||||
"session-malformed",
|
||||
"session-retry",
|
||||
"network-error",
|
||||
].includes(mode));
|
||||
const setup = new URL("/", origin);
|
||||
setup.searchParams.set("fixture", mode);
|
||||
await page.request.get(setup.href);
|
||||
await page.goto(new URL(path, origin).href);
|
||||
},
|
||||
});
|
||||
await noOverflow(page);
|
||||
assertEquals(errors, []);
|
||||
// root load自身の503ではKitのstatic fallback再実行も同じHttpErrorをrejectする。
|
||||
// minifyされたclass名で除外せず、意図した障害fixtureのstatus/bodyと件数だけを許す。
|
||||
if (expectsUnavailableRoot && rejectedLoads.length > 0) {
|
||||
for (const reason of rejectedLoads) {
|
||||
assertEquals(reason, {
|
||||
status: 503,
|
||||
body: {
|
||||
status: 503,
|
||||
message:
|
||||
"Your session could not be loaded. Login status is unknown. Retry this page.",
|
||||
},
|
||||
});
|
||||
}
|
||||
assertEquals(errors.length, rejectedLoads.length);
|
||||
} else assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -113,12 +151,13 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
readyUrl: `${origin}/health`,
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
|
||||
`${root}/target/web-ux/auth-interactions/processes`,
|
||||
`${root}/target/web-ux/t767-validation/auth-interactions/processes`,
|
||||
[],
|
||||
);
|
||||
let browser: Browser | undefined;
|
||||
try {
|
||||
browser = await chromium.launch();
|
||||
await sessionNavigation(t, browser, isolated, origin);
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const step = (
|
||||
@@ -130,11 +169,11 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
() => isolated(browser!, body, theme, width),
|
||||
);
|
||||
await step(
|
||||
"anonymous account replaces history with login then returns to own account",
|
||||
"expired 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 go("/account", "session-expired");
|
||||
await at(page, "/login?returnTo=%2Faccount");
|
||||
assertEquals(
|
||||
await page.evaluate(() => history.length),
|
||||
@@ -208,16 +247,12 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
);
|
||||
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.getByRole("heading", { name: "Yoi", exact: true })
|
||||
.waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/");
|
||||
await page.reload();
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await page.getByRole("heading", { name: "Yoi", exact: true })
|
||||
.waitFor();
|
||||
assertEquals(count(mutations, "/api/auth/logout"), 1);
|
||||
},
|
||||
);
|
||||
@@ -410,10 +445,15 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go, mutations }) => {
|
||||
await go(path, "session-error");
|
||||
await page.getByRole("button", {
|
||||
name: "Retry session",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
if (path === "/account") {
|
||||
// 初回protected loadの確認不能はroot loaderの503。画面内再確認とは分ける。
|
||||
await page.getByText(/Login status is unknown/i).waitFor();
|
||||
} else {
|
||||
await page.getByRole("button", {
|
||||
name: "Retry session",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
}
|
||||
assertEquals(
|
||||
await page.getByRole("link", {
|
||||
name: "Log in to continue",
|
||||
@@ -627,6 +667,9 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
"/register?returnTo=%2Flogin",
|
||||
"/activate",
|
||||
"/login/../login",
|
||||
"/%2f%2fexample.invalid",
|
||||
"/account?filter=%252F%252Fexample.invalid",
|
||||
"/account#%5Cexample.invalid",
|
||||
]
|
||||
) {
|
||||
await t.step(
|
||||
@@ -713,7 +756,11 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
|
||||
0,
|
||||
);
|
||||
await page.getByRole("button", { name: label, exact: true })
|
||||
await page.getByRole("button", {
|
||||
name: label,
|
||||
exact: true,
|
||||
includeHidden: true,
|
||||
})
|
||||
.evaluate((button) => {
|
||||
button.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
|
||||
@@ -10,6 +10,10 @@ import type {
|
||||
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const phase = Deno.args[0] ?? "after";
|
||||
const coverage = Deno.args[1] ?? "all";
|
||||
if (coverage !== "all" && coverage !== "session") {
|
||||
throw new Error("Expected capture coverage: all or session");
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(phase)) {
|
||||
throw new Error("Expected safe capture label");
|
||||
}
|
||||
@@ -86,7 +90,39 @@ const routes: RouteScenario[] = [
|
||||
route(
|
||||
"account-redirect",
|
||||
"/account?fixture=anonymous",
|
||||
'button:text-is("Log in with passkey")',
|
||||
'h1:text-is("Yoi")',
|
||||
),
|
||||
route(
|
||||
"expired-account",
|
||||
"/account?fixture=session-expired&tab=keys#passkeys",
|
||||
'[role="status"]:has-text("Your browser session expired")',
|
||||
),
|
||||
route(
|
||||
"expired-login",
|
||||
"/login?fixture=session-expired-cancel-once&returnTo=%2Faccount%3Ftab%3Dkeys%23passkeys",
|
||||
'[role="status"]:has-text("Your browser session expired")',
|
||||
[
|
||||
{
|
||||
id: "initial",
|
||||
label: "Explicit expiry and login continuation, not anonymous entry",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "cancelled",
|
||||
label: "Cancelled passkey keeps expiry context and retry destination",
|
||||
fullPage: true,
|
||||
interaction: login,
|
||||
ready: ready('[role="alert"]:has-text("cancelled")'),
|
||||
},
|
||||
{
|
||||
id: "returned",
|
||||
label:
|
||||
"Retry confirms authentication before returning to the requested account",
|
||||
fullPage: true,
|
||||
interaction: login,
|
||||
ready: ready('button:text-is("Add passkey to alice")'),
|
||||
},
|
||||
],
|
||||
),
|
||||
route("register", "/register?fixture=anonymous", `${id}:enabled`, [
|
||||
{
|
||||
@@ -129,10 +165,10 @@ const routes: RouteScenario[] = [
|
||||
},
|
||||
{
|
||||
id: "logout",
|
||||
label: "Log out returns to login",
|
||||
label: "Log out forgets expiry and returns to public home",
|
||||
fullPage: true,
|
||||
interaction: [click('button:text-is("Log out")')],
|
||||
ready: ready('button:text-is("Log in with passkey")'),
|
||||
ready: ready('h1:text-is("Yoi")'),
|
||||
},
|
||||
],
|
||||
),
|
||||
@@ -184,7 +220,7 @@ const routes: RouteScenario[] = [
|
||||
route(
|
||||
"account-session-error",
|
||||
"/account?fixture=session-error",
|
||||
'button:text-is("Retry session")',
|
||||
"text=/Login status is unknown/",
|
||||
),
|
||||
route(
|
||||
"register-session-error",
|
||||
@@ -297,6 +333,14 @@ const routes: RouteScenario[] = [
|
||||
],
|
||||
),
|
||||
];
|
||||
const sessionCaptureIds = new Set([
|
||||
"login",
|
||||
"account-redirect",
|
||||
"expired-account",
|
||||
"expired-login",
|
||||
"session-error",
|
||||
"account-session-error",
|
||||
]);
|
||||
await Deno.mkdir(`${root}/target/web-ux`, { recursive: true });
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
@@ -314,7 +358,9 @@ for (const colorScheme of ["light", "dark"] as const) {
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [{ width, height: 900, label: `${width}` }],
|
||||
routes,
|
||||
routes: coverage === "session"
|
||||
? routes.filter((route) => sessionCaptureIds.has(route.id))
|
||||
: routes,
|
||||
redact: {
|
||||
// 初期撮影後にもfillする。空の入力まで恒久的に隠すと次の操作が不能になる。
|
||||
// 合成codeはsubmit直後に破棄され、pendingのdisabled入力だけを隠す。
|
||||
|
||||
@@ -48,7 +48,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
},
|
||||
],
|
||||
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
|
||||
`${root}/target/web-ux/entry-browser/processes`,
|
||||
`${root}/target/web-ux/t767-validation/entry-browser/processes`,
|
||||
[],
|
||||
);
|
||||
try {
|
||||
@@ -230,16 +230,8 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
];
|
||||
await page.getByRole("button", { name: "Log out", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Log in with passkey" })
|
||||
.waitFor();
|
||||
retainedCounts.push(
|
||||
await page.locator(".workspace-alert").count(),
|
||||
);
|
||||
await page.getByRole("link", {
|
||||
name: "Dashboard / Workspaces",
|
||||
exact: true,
|
||||
}).click();
|
||||
await ready(page, "Log in");
|
||||
assertEquals(new URL(page.url()).pathname, "/");
|
||||
retainedCounts.push(
|
||||
await page.locator(".workspace-alert").count(),
|
||||
);
|
||||
@@ -253,8 +245,11 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
await page.getByRole("link", { name: "Yoi home", exact: true })
|
||||
.click();
|
||||
await ready(page, "Log in");
|
||||
retainedCounts.push(
|
||||
await page.locator(".workspace-alert").count(),
|
||||
);
|
||||
const folder =
|
||||
`${root}/target/web-ux/entry-browser/private-alerts-${colorScheme}-${width}`;
|
||||
`${root}/target/web-ux/t767-validation/entry-browser/private-alerts-${colorScheme}-${width}`;
|
||||
await Deno.mkdir(folder, { recursive: true });
|
||||
const bytes = await page.screenshot({
|
||||
path: folder + "/viewport.png",
|
||||
@@ -281,7 +276,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
JSON.stringify(
|
||||
{
|
||||
scenario:
|
||||
"Workspace Stop notification → Account logout → dashboard/login → public home (SPA)",
|
||||
"Workspace Stop notification → Account logout → public home → login → public home (SPA)",
|
||||
boundary:
|
||||
"synthetic HTTP and Worker subscription; production UI/router/store",
|
||||
path: new URL(page.url()).pathname,
|
||||
@@ -590,13 +585,19 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
session: {
|
||||
kind: "authenticated",
|
||||
expires_at: "2099-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("focus"))
|
||||
);
|
||||
await page.getByText("Loading session…", { exact: true })
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Loading session…",
|
||||
})
|
||||
.waitFor();
|
||||
const completed = page.waitForResponse((response) =>
|
||||
response.request().method() === "POST" &&
|
||||
@@ -611,9 +612,10 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
)
|
||||
);
|
||||
assertEquals(new URL(page.url()).pathname, "/dashboard");
|
||||
// 確認中のsubtreeは副作用の結果を保持したままhidden/inertになる。
|
||||
assertEquals(
|
||||
await page.getByLabel("Workspace display name").count(),
|
||||
0,
|
||||
await page.getByLabel("Workspace display name").isVisible(),
|
||||
false,
|
||||
);
|
||||
releaseSession();
|
||||
const link = page.getByRole("link", {
|
||||
@@ -630,7 +632,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
0,
|
||||
);
|
||||
const folder =
|
||||
`${root}/target/web-ux/entry-browser/confirmed-${colorScheme}-${width}`;
|
||||
`${root}/target/web-ux/t767-validation/entry-browser/confirmed-${colorScheme}-${width}`;
|
||||
await Deno.mkdir(folder, { recursive: true });
|
||||
const bytes = await page.screenshot({
|
||||
path: folder + "/viewport.png",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 本番build/client/shellだけを検証する合成HTTP/WebAuthn境界。
|
||||
// 実認証・署名・cookie安全性・policyの永続化は検証しない。本文/credentialは記録しない。
|
||||
// 実認証・署名・製品cookie設計・policyの永続化は検証しない。本文/credentialは記録しない。
|
||||
// cookie-sessionは実HTTPのMax-AgeをChromiumが失効させる境界だけを検証する。Backendの判定は合成。
|
||||
import { extname, join, resolve } from "@std/path";
|
||||
import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts";
|
||||
|
||||
@@ -22,6 +23,9 @@ type State = {
|
||||
signedIn: boolean;
|
||||
whoamiCalls: number;
|
||||
selfRegistrationEnabled: boolean;
|
||||
expired: boolean;
|
||||
expiresAt: string | null;
|
||||
cookieSession: boolean;
|
||||
};
|
||||
const sessions = new Map<string, State>();
|
||||
const mime: Record<string, string> = {
|
||||
@@ -62,7 +66,10 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
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)) {
|
||||
if (
|
||||
(selected && sessions.get(id ?? "")?.mode !== selected) || !id ||
|
||||
!sessions.has(id)
|
||||
) {
|
||||
id = crypto.randomUUID();
|
||||
sessions.set(id, {
|
||||
mode: selected ?? "anonymous",
|
||||
@@ -72,13 +79,54 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
selected?.startsWith("addition-") === true,
|
||||
whoamiCalls: 0,
|
||||
selfRegistrationEnabled: selected !== "policy-disabled",
|
||||
expired: selected?.startsWith("session-expired") === true,
|
||||
expiresAt: null,
|
||||
cookieSession: false,
|
||||
});
|
||||
}
|
||||
const state = sessions.get(id)!;
|
||||
const { mode } = state;
|
||||
const sessionCookiePresent = request.headers.get("cookie")?.split(/;\s*/)
|
||||
.includes("auth-fixture-session=active") === true;
|
||||
if (state.cookieSession && !sessionCookiePresent) {
|
||||
state.signedIn = false;
|
||||
state.expired = true;
|
||||
}
|
||||
// 制御は同一browser contextの合成fixtureに限定。認証・cookieをJSで偽造しない。
|
||||
if (path === "/__ui_fixture/session") {
|
||||
if (request.method === "POST") {
|
||||
const { operation } = await request.json();
|
||||
if (operation === "arm-cookie") {
|
||||
state.signedIn = true;
|
||||
state.expired = false;
|
||||
state.cookieSession = true;
|
||||
state.expiresAt = new Date(Date.now() + 3000).toISOString();
|
||||
return Response.json({ expires_at: state.expiresAt }, {
|
||||
headers: {
|
||||
"set-cookie":
|
||||
"auth-fixture-session=active; Max-Age=3; Path=/; HttpOnly; SameSite=Strict",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (operation === "expire" || operation === "anonymous") {
|
||||
state.signedIn = false;
|
||||
state.expired = operation === "expire";
|
||||
state.cookieSession = false;
|
||||
state.expiresAt = null;
|
||||
} else return new Response(null, { status: 400 });
|
||||
}
|
||||
return Response.json({
|
||||
session_cookie_present: sessionCookiePresent,
|
||||
whoami_calls: state.whoamiCalls,
|
||||
expired: state.expired,
|
||||
});
|
||||
}
|
||||
if (path === "/__ui_fixture/webauthn.js") {
|
||||
let extra = "";
|
||||
if (mode.endsWith("-cancel")) {
|
||||
if (mode.endsWith("-cancel-once")) {
|
||||
extra =
|
||||
`for (const method of ['get','create']) { const original=navigator.credentials[method].bind(navigator.credentials); let first=true; Object.defineProperty(navigator.credentials,method,{configurable:true,value:async(...args)=>{if(first){first=false;throw new DOMException('Passkey cancelled','NotAllowedError')}return original(...args)}}); }`;
|
||||
} else 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("-unsupported")) {
|
||||
@@ -116,6 +164,13 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
actor: state.signedIn
|
||||
? { ...user, auth_method: "browser_session" }
|
||||
: null,
|
||||
session: state.signedIn
|
||||
? {
|
||||
kind: "authenticated",
|
||||
expires_at: state.expiresAt ??
|
||||
new Date(Date.now() + 86_400_000).toISOString(),
|
||||
}
|
||||
: { kind: state.expired ? "expired" : "anonymous" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/registration-policy") {
|
||||
@@ -160,7 +215,15 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
}
|
||||
if (path === "/api/auth/logout") {
|
||||
state.signedIn = false;
|
||||
return Response.json({ status: "logged_out" });
|
||||
state.expired = false;
|
||||
state.cookieSession = false;
|
||||
state.expiresAt = null;
|
||||
return Response.json({ status: "logged_out" }, {
|
||||
headers: {
|
||||
"set-cookie":
|
||||
"auth-fixture-session=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/device-login/approve") {
|
||||
if (!state.signedIn) return new Response(null, { status: 401 });
|
||||
@@ -187,7 +250,7 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
if (mode === `${purpose}-expired` && stage === "complete") {
|
||||
if (purpose === "addition") state.signedIn = false;
|
||||
// challengeの401だけではbrowser sessionの失効を主張しない。
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
if (stage === "options") {
|
||||
@@ -197,6 +260,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
);
|
||||
}
|
||||
state.signedIn = true;
|
||||
state.expired = false;
|
||||
state.cookieSession = false;
|
||||
state.expiresAt = null;
|
||||
return Response.json({ user });
|
||||
}
|
||||
if (path === "/api/auth/activation/options") {
|
||||
@@ -204,6 +270,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
}
|
||||
if (path === "/api/auth/activation/complete") {
|
||||
state.signedIn = true;
|
||||
state.expired = false;
|
||||
state.cookieSession = false;
|
||||
state.expiresAt = null;
|
||||
return Response.json({ user });
|
||||
}
|
||||
// Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
/// <reference lib="dom" />
|
||||
// auth-shellの本番build/HTTP/WebAuthn fixtureと隔離contextを再利用する。
|
||||
// Max-Ageは本物のHTTP cookieをbrowserが消すことを観測するが、whoami分類は合成。
|
||||
// 実Serverのsession/marker/revoke判定の証明ではない。
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import type { Browser, Page } from "playwright";
|
||||
|
||||
type Harness = {
|
||||
page: Page;
|
||||
mutations: { path: string; keys: string[] }[];
|
||||
go: (path: string, mode?: string, unavailableRoot?: boolean) => Promise<void>;
|
||||
};
|
||||
type Isolated = (
|
||||
browser: Browser,
|
||||
body: (harness: Harness) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
|
||||
export async function sessionNavigation(
|
||||
t: Deno.TestContext,
|
||||
browser: Browser,
|
||||
isolated: Isolated,
|
||||
origin: string,
|
||||
) {
|
||||
const step = (name: string, body: (harness: Harness) => Promise<void>) =>
|
||||
t.step(name, () => isolated(browser, body));
|
||||
const login = (page: Page) =>
|
||||
page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||
.click();
|
||||
const at = (page: Page, target: string) =>
|
||||
page.waitForURL((url) =>
|
||||
url.origin === origin && url.pathname + url.search + url.hash === target
|
||||
);
|
||||
const loginTarget = (target: string) =>
|
||||
`/login?${new URLSearchParams({ returnTo: target })}`;
|
||||
const control = async (page: Page, operation: string) => {
|
||||
const response = await page.request.post(`${origin}/__ui_fixture/session`, {
|
||||
data: { operation },
|
||||
});
|
||||
assertEquals(response.status(), 200);
|
||||
return response;
|
||||
};
|
||||
const focus = (page: Page) =>
|
||||
page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
const noPrivateRequests = (page: Page) => {
|
||||
const paths: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (/^\/api\/(workspaces|w\/|hosting\/)/.test(path)) paths.push(path);
|
||||
});
|
||||
return paths;
|
||||
};
|
||||
for (
|
||||
const target of [
|
||||
"/w/team/tickets?filter=100%25#entry",
|
||||
"/w/team/tickets/%e6%9c%aa?filter=100%25#part%25",
|
||||
]
|
||||
) {
|
||||
await step(
|
||||
`expiry retains encoded URL data ${target}`,
|
||||
async ({ page, go }) => {
|
||||
const privateRequests = noPrivateRequests(page);
|
||||
await go(target, "session-expired");
|
||||
await at(page, loginTarget(target));
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await page.reload();
|
||||
await at(page, loginTarget(target));
|
||||
assertEquals(privateRequests, []);
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"successful login retains percent query and fragment data",
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?filter=100%25#part%25";
|
||||
await go(target, "session-expired");
|
||||
await at(page, loginTarget(target));
|
||||
await login(page);
|
||||
await at(page, target);
|
||||
await page.getByRole("button", { name: "Log out", exact: true })
|
||||
.waitFor();
|
||||
},
|
||||
);
|
||||
const protectedRoutes = [
|
||||
"/dashboard",
|
||||
"/account",
|
||||
"/hosting/settings",
|
||||
"/w/private/tickets",
|
||||
];
|
||||
for (const path of protectedRoutes) {
|
||||
await step(
|
||||
`fresh anonymous ${path} goes to public root before private loaders`,
|
||||
async ({ page, go, mutations }) => {
|
||||
const privateRequests = noPrivateRequests(page);
|
||||
await go(`${path}?view=private#selected`);
|
||||
await at(page, "/");
|
||||
await page.getByRole("heading", { name: "Yoi", exact: true }).waitFor();
|
||||
assertEquals(await page.locator(".app-shell").count(), 0);
|
||||
assertEquals(privateRequests, []);
|
||||
assertEquals(mutations, []);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
`expired ${path} preserves pathname query and fragment before private loaders`,
|
||||
async ({ page, go }) => {
|
||||
const target = `${path}?filter=a%2Fb&view=thread#selected`;
|
||||
const privateRequests = noPrivateRequests(page);
|
||||
await go(target, "session-expired");
|
||||
await at(page, loginTarget(target));
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(privateRequests, []);
|
||||
},
|
||||
);
|
||||
}
|
||||
for (
|
||||
const [path, selector] of [
|
||||
["/", "h1"],
|
||||
["/login", "button[type=submit]"],
|
||||
["/register", "#registration-handle"],
|
||||
["/activate", "#activation-handle"],
|
||||
["/login/device?user_code=ABCD-EFGH", 'a[href^="/login?"]'],
|
||||
["/hosting/setup", "#host-setup-token"],
|
||||
]
|
||||
) {
|
||||
await step(
|
||||
`anonymous public entry ${path} does not redirect or load private data`,
|
||||
async ({ page, go }) => {
|
||||
const privateRequests = noPrivateRequests(page);
|
||||
await go(path);
|
||||
// 固有controlのrenderまで待ち、単なるdocument load成功で済ませない。
|
||||
if (path === "/register") {
|
||||
await page.getByLabel("Display name", { exact: true }).waitFor();
|
||||
} else await page.locator(selector).first().waitFor();
|
||||
await at(page, path);
|
||||
assertEquals(privateRequests, []);
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"expired login reload cancellation and retry keep the complete original URL",
|
||||
async ({ page, go, mutations }) => {
|
||||
const target = "/account?tab=keys&filter=a%2Fb#passkeys";
|
||||
await go(target, "session-expired-cancel-once");
|
||||
await at(page, loginTarget(target));
|
||||
await page.reload();
|
||||
await login(page);
|
||||
await page.getByRole("alert").filter({ hasText: /cancelled|timed out/i })
|
||||
.waitFor();
|
||||
await at(page, loginTarget(target));
|
||||
assertEquals(
|
||||
mutations.filter((m) => m.path.endsWith("/login/complete")).length,
|
||||
0,
|
||||
);
|
||||
await login(page);
|
||||
await at(page, target);
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await page.reload();
|
||||
await at(page, target);
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
},
|
||||
);
|
||||
for (
|
||||
const mode of [
|
||||
"session-error",
|
||||
"session-malformed",
|
||||
"session-loading",
|
||||
"network-error",
|
||||
]
|
||||
) {
|
||||
await step(
|
||||
`protected ${mode} stays unknown without redirect or private loaders`,
|
||||
async ({ page, go }) => {
|
||||
if (mode === "network-error") {
|
||||
await page.route(
|
||||
"**/api/auth/whoami",
|
||||
(route) => route.abort("failed"),
|
||||
);
|
||||
}
|
||||
const privateRequests = noPrivateRequests(page);
|
||||
const target = "/account?tab=keys#passkeys";
|
||||
await go(target, mode);
|
||||
if (mode === "session-loading") {
|
||||
// 実要求がpendingであることを観測する。loadingの文言はloaderと画面で異なる。
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
} else await page.getByText(/Login status is unknown/i).waitFor();
|
||||
await at(page, target);
|
||||
assertEquals(privateRequests, []);
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"protected unknown can reload and retry without losing query or fragment",
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?tab=keys#passkeys";
|
||||
let failed = true;
|
||||
await page.route(
|
||||
"**/api/auth/whoami",
|
||||
(route) => failed ? route.fulfill({ status: 503 }) : route.continue(),
|
||||
);
|
||||
await go(target, "account", true);
|
||||
await page.getByText(/Login status is unknown/i).waitFor();
|
||||
await at(page, target);
|
||||
failed = false;
|
||||
await page.reload();
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await at(page, target);
|
||||
},
|
||||
);
|
||||
for (const active of [false, true]) {
|
||||
await step(
|
||||
`real HTTP Max-Age cookie disappears and ${
|
||||
active ? "active expiry" : "reload"
|
||||
} returns through login`,
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?tab=keys#after-cookie-expiry";
|
||||
await go(target, "account");
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
const armed = await control(page, "arm-cookie");
|
||||
assert(armed.headers()["set-cookie"].includes("Max-Age=3"));
|
||||
const cookie = (await page.context().cookies()).find((c) =>
|
||||
c.name === "auth-fixture-session"
|
||||
);
|
||||
assert(cookie?.httpOnly);
|
||||
assert(cookie.expires > Date.now() / 1000);
|
||||
if (active) {
|
||||
// whoamiのexpires_atを製品timerへ渡す。テスト側clockの前進やcookie削除はしない。
|
||||
const refreshed = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/whoami"
|
||||
);
|
||||
await focus(page);
|
||||
const wire = await (await refreshed).json();
|
||||
assertEquals(wire.session.kind, "authenticated");
|
||||
assertEquals(
|
||||
wire.session.expires_at,
|
||||
(await armed.json()).expires_at,
|
||||
);
|
||||
}
|
||||
// 固定sleepではなくHTTP要求にcookieが付かなくなる事実で同期する。
|
||||
await page.evaluate(async () => {
|
||||
const deadline = performance.now() + 10_000;
|
||||
do {
|
||||
const observation = await (await fetch("/__ui_fixture/session", {
|
||||
cache: "no-store",
|
||||
})).json();
|
||||
if (observation.session_cookie_present === false) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} while (performance.now() < deadline);
|
||||
throw new Error("Primary session cookie did not naturally expire");
|
||||
});
|
||||
assertEquals(
|
||||
(await page.context().cookies()).some((c) =>
|
||||
c.name === "auth-fixture-session"
|
||||
),
|
||||
false,
|
||||
);
|
||||
// 長寿命の識別補助は残る。全消去とは異なる条件を作ったことも確認する。
|
||||
assert(
|
||||
(await page.context().cookies()).some((c) =>
|
||||
c.name === "auth-fixture"
|
||||
),
|
||||
);
|
||||
if (!active) await page.reload();
|
||||
await at(page, loginTarget(target));
|
||||
await page.reload();
|
||||
await login(page);
|
||||
await at(page, target);
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"concurrent API 401 and repeated session checks cannot overwrite the original expired return URL",
|
||||
async ({ page, go }) => {
|
||||
const target = "/w/private/tickets?view=thread#concurrent-expiry";
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => release = resolve);
|
||||
const statuses: number[] = [];
|
||||
// 本番Workspace loaderの並列requestを止める。window.fetchの呼出しだけで
|
||||
// 製品API clientの401再照会を通したことにはしない。
|
||||
await page.route("**/api/w/private/**", async (route) => {
|
||||
await held;
|
||||
statuses.push(401);
|
||||
await route.fulfill({ status: 401 });
|
||||
});
|
||||
const requests = Promise.all([
|
||||
page.waitForRequest((r) =>
|
||||
new URL(r.url()).pathname === "/api/w/private/workspace"
|
||||
),
|
||||
page.waitForRequest((r) =>
|
||||
new URL(r.url()).pathname === "/api/w/private/repositories"
|
||||
),
|
||||
]);
|
||||
try {
|
||||
await go(target, "account");
|
||||
await requests;
|
||||
await control(page, "expire");
|
||||
release();
|
||||
// focus等の外部契機なしに、同時401がwhoami再照会へ接続すること。
|
||||
await at(page, loginTarget(target));
|
||||
assert(statuses.length >= 2);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const response = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/whoami"
|
||||
);
|
||||
await focus(page);
|
||||
await (await response).finished();
|
||||
}
|
||||
await at(page, loginTarget(target));
|
||||
await page.unroute("**/api/w/private/**");
|
||||
await login(page);
|
||||
await at(page, target);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"delayed pre-logout authenticated response cannot restore private UI or an old return URL",
|
||||
async ({ page, go }) => {
|
||||
await go("/account?tab=keys#stale", "account");
|
||||
await page.getByRole("button", { name: "Log out", exact: true })
|
||||
.waitFor();
|
||||
let started!: () => void;
|
||||
let failed!: (error: unknown) => void;
|
||||
let release!: () => void;
|
||||
const requested = new Promise<void>((resolve, reject) => {
|
||||
started = resolve;
|
||||
failed = reject;
|
||||
});
|
||||
const held = new Promise<void>((resolve) => release = resolve);
|
||||
let first = true;
|
||||
await page.route("**/api/auth/whoami", async (route) => {
|
||||
if (!first) return route.continue();
|
||||
first = false;
|
||||
try {
|
||||
const old = await route.fetch({ timeout: 5000 });
|
||||
started();
|
||||
await held;
|
||||
await route.fulfill({
|
||||
response: old,
|
||||
headers: { ...old.headers(), "x-auth-fixture-held": "true" },
|
||||
});
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const observed = page.waitForRequest((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/whoami"
|
||||
);
|
||||
await focus(page);
|
||||
await observed;
|
||||
await requested;
|
||||
// 他tabの本物の製品logoutでgeneration/storage eventも通す。
|
||||
const other = await page.context().newPage();
|
||||
try {
|
||||
await other.goto(`${origin}/account`);
|
||||
await other.getByRole("button", { name: "Log out", exact: true })
|
||||
.click();
|
||||
await at(other, "/");
|
||||
const delivered = page.waitForResponse((r) =>
|
||||
r.headers()["x-auth-fixture-held"] === "true"
|
||||
);
|
||||
release();
|
||||
await (await delivered).finished();
|
||||
// 古い応答を実際に製品へ届け、readSession/renderのtask境界まで進める。
|
||||
await page.evaluate(() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => resolve())
|
||||
)
|
||||
)
|
||||
);
|
||||
await at(page, "/");
|
||||
assertEquals(new URL(page.url()).searchParams.has("returnTo"), false);
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
await page.goto(`${origin}/account`);
|
||||
await at(page, "/");
|
||||
} finally {
|
||||
await other.close();
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"challenge 401 leaves an authenticated account in place and permits retry",
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?tab=keys#challenge";
|
||||
await go(target, "addition-expired");
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("alert").waitFor();
|
||||
await at(page, target);
|
||||
assertEquals(
|
||||
(await (await page.request.get(`${origin}/api/auth/whoami`)).json())
|
||||
.session.kind,
|
||||
"authenticated",
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).isEnabled(),
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"explicit logout and complete browser storage removal classify as anonymous, not expired",
|
||||
async ({ page, go }) => {
|
||||
await go("/account", "account");
|
||||
await control(page, "arm-cookie");
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).click();
|
||||
await at(page, "/");
|
||||
const wire = await (await page.request.get(`${origin}/api/auth/whoami`))
|
||||
.json();
|
||||
assertEquals(wire, { actor: null, session: { kind: "anonymous" } });
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
await page.context().clearCookies();
|
||||
await page.goto(`${origin}/account?tab=keys#cleared`);
|
||||
await at(page, "/");
|
||||
assertEquals(new URL(page.url()).searchParams.has("returnTo"), false);
|
||||
},
|
||||
);
|
||||
for (const event of ["pageshow", "visibilitychange"] as const) {
|
||||
await step(
|
||||
`${event} rechecks active session and preserves expired return URL`,
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?tab=keys#lifecycle";
|
||||
await go(target, "account");
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await control(page, "expire");
|
||||
await page.evaluate((event) => {
|
||||
if (event === "visibilitychange") {
|
||||
if (document.hidden) {
|
||||
throw new Error("Expected a visible test page");
|
||||
}
|
||||
document.dispatchEvent(new Event(event));
|
||||
} else window.dispatchEvent(new Event(event));
|
||||
}, event);
|
||||
await at(page, loginTarget(target));
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"forget expired session clears returnTo and fragment before a later login",
|
||||
async ({ page, go, mutations }) => {
|
||||
await go("/account?tab=keys#forgotten", "session-expired");
|
||||
await page.getByRole("button", {
|
||||
name: "Forget this session",
|
||||
exact: true,
|
||||
}).click();
|
||||
await at(page, "/login");
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
(await (await page.request.get(`${origin}/api/auth/whoami`)).json())
|
||||
.session.kind,
|
||||
"anonymous",
|
||||
);
|
||||
assertEquals(
|
||||
mutations.filter((m) => m.path === "/api/auth/logout").length,
|
||||
1,
|
||||
);
|
||||
await login(page);
|
||||
await at(page, "/dashboard");
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"forget session is allowed on unknown login and never claims authenticated success",
|
||||
async ({ page, go, mutations }) => {
|
||||
await go(
|
||||
"/login?returnTo=%2Faccount%3Ftab%3Dkeys%23forgotten#entry",
|
||||
"session-error",
|
||||
);
|
||||
await page.getByText(/Login status is unknown/i).waitFor();
|
||||
await page.getByRole("button", {
|
||||
name: "Forget this session",
|
||||
exact: true,
|
||||
}).click();
|
||||
await at(page, "/login");
|
||||
await page.getByText(/Login status is unknown/i).waitFor();
|
||||
assertEquals(
|
||||
mutations.filter((m) => m.path === "/api/auth/logout").length,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: /^Continue to/ }).count(),
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"other-tab logout clears a pending expired login return URL",
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?tab=keys#other-tab-logout";
|
||||
await go(target, "session-expired");
|
||||
await at(page, loginTarget(target));
|
||||
const other = await page.context().newPage();
|
||||
try {
|
||||
await other.goto(`${origin}${loginTarget(target)}`);
|
||||
await other.getByRole("button", {
|
||||
name: "Forget this session",
|
||||
exact: true,
|
||||
}).click();
|
||||
await at(other, "/login");
|
||||
await at(page, "/login");
|
||||
await login(page);
|
||||
await at(page, "/dashboard");
|
||||
} finally {
|
||||
await other.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
for (const status of [403, 404]) {
|
||||
await step(
|
||||
`post-login resource ${status} remains a resource failure, never a relogin loop`,
|
||||
async ({ page, go }) => {
|
||||
const target = "/dashboard?selected=missing#resource";
|
||||
let whoamiCalls = 0;
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname === "/api/auth/whoami") {
|
||||
whoamiCalls++;
|
||||
}
|
||||
});
|
||||
await page.route(
|
||||
"**/api/workspaces?*",
|
||||
(route) => route.fulfill({ status }),
|
||||
);
|
||||
await go(target, "session-expired");
|
||||
await at(page, loginTarget(target));
|
||||
await login(page);
|
||||
await at(page, target);
|
||||
await page.getByText(/Workspaces could not be loaded/).waitFor();
|
||||
const before = whoamiCalls;
|
||||
await page.reload();
|
||||
await page.getByText(/Workspaces could not be loaded/).waitFor();
|
||||
await at(page, target);
|
||||
// reloadごとの有限なreadで、403/404をexpiredへ変換してloopしない。
|
||||
assert(whoamiCalls - before <= 4);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
import { extname, join, resolve } from "@std/path";
|
||||
import { setupProjectionResponse } from "./setup-projection.ts";
|
||||
const [port, build] = Deno.args;
|
||||
if (!port || !build) throw new Error("usage: fixture-server.ts <port> <build-root>");
|
||||
if (!port || !build) {
|
||||
throw new Error("usage: fixture-server.ts <port> <build-root>");
|
||||
}
|
||||
const buildRoot = resolve(build);
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
@@ -23,9 +25,15 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
const selected = url.searchParams.get("fixture");
|
||||
if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) setupMode = selected;
|
||||
const mode = selected ?? request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ??
|
||||
(url.pathname.startsWith("/api/auth/passkeys/addition/") ? setupMode : null) ?? "admin-on";
|
||||
if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) {
|
||||
setupMode = selected;
|
||||
}
|
||||
const mode = selected ??
|
||||
request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ??
|
||||
(url.pathname.startsWith("/api/auth/passkeys/addition/")
|
||||
? setupMode
|
||||
: null) ??
|
||||
"admin-on";
|
||||
if (selected) policies.delete(mode);
|
||||
const setup = setupProjectionResponse(url.pathname, mode, selected !== null);
|
||||
if (setup) return setup;
|
||||
@@ -33,6 +41,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
const ordinary = mode.startsWith("ordinary");
|
||||
if (url.pathname === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
session: !anonymous
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: anonymous ? null : {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
@@ -46,7 +57,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
if (url.pathname === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: !ordinary && !anonymous });
|
||||
}
|
||||
const policy = () => ({ self_registration_enabled: policies.get(mode) ?? !mode.includes("off") });
|
||||
const policy = () => ({
|
||||
self_registration_enabled: policies.get(mode) ?? !mode.includes("off"),
|
||||
});
|
||||
if (url.pathname === "/api/auth/registration-policy") {
|
||||
if (mode === "account-error") {
|
||||
return new Response("synthetic availability failure", { status: 503 });
|
||||
@@ -56,12 +69,17 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
}
|
||||
if (url.pathname === "/api/hosting/settings") {
|
||||
if (anonymous || ordinary) {
|
||||
return new Response("synthetic denied", { status: anonymous ? 401 : 403 });
|
||||
return new Response("synthetic denied", {
|
||||
status: anonymous ? 401 : 403,
|
||||
});
|
||||
}
|
||||
if (mode === "admin-load-error") {
|
||||
return new Response("synthetic settings failure", { status: 503 });
|
||||
}
|
||||
if (mode === "admin-loading" || (mode === "admin-saving" && request.method === "PUT")) {
|
||||
if (
|
||||
mode === "admin-loading" ||
|
||||
(mode === "admin-saving" && request.method === "PUT")
|
||||
) {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
@@ -80,13 +98,18 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
return new Response("unexpected fixture API", { status: 404 });
|
||||
}
|
||||
const path = join(buildRoot, decodeURIComponent(url.pathname));
|
||||
if (!path.startsWith(buildRoot + "/")) return new Response("invalid path", { status: 400 });
|
||||
if (!path.startsWith(buildRoot + "/")) {
|
||||
return new Response("invalid path", { status: 400 });
|
||||
}
|
||||
let bytes: Uint8Array;
|
||||
let extension = extname(path);
|
||||
try {
|
||||
bytes = await Deno.readFile(path);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound) && !(error instanceof Deno.errors.IsADirectory)) {
|
||||
if (
|
||||
!(error instanceof Deno.errors.NotFound) &&
|
||||
!(error instanceof Deno.errors.IsADirectory)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
bytes = await Deno.readFile(join(buildRoot, "index.html"));
|
||||
@@ -106,6 +129,11 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
"content-type": mime[extension] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
if (selected) headers.set("set-cookie", `ui-fixture=${selected}; Path=/; SameSite=Strict`);
|
||||
if (selected) {
|
||||
headers.set(
|
||||
"set-cookie",
|
||||
`ui-fixture=${selected}; Path=/; SameSite=Strict`,
|
||||
);
|
||||
}
|
||||
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
|
||||
});
|
||||
|
||||
@@ -189,12 +189,14 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
|
||||
await page.goto(
|
||||
`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`,
|
||||
);
|
||||
await page.getByText(
|
||||
mode === "ordinary"
|
||||
? "Hosting administrator access is required."
|
||||
: "Sign in with a hosting administrator account to view these settings.",
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
if (mode === "anonymous") {
|
||||
await page.waitForURL("http://127.0.0.1:15379/");
|
||||
await page.getByRole("heading", { name: "Yoi", exact: true })
|
||||
.waitFor();
|
||||
} else {await page.getByText(
|
||||
"Hosting administrator access is required.",
|
||||
{ exact: true },
|
||||
).waitFor();}
|
||||
assertEquals(await edit.count(), 0);
|
||||
assertEquals(await checkbox.count(), 0);
|
||||
assertEquals(
|
||||
@@ -285,16 +287,19 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
|
||||
await page.locator(ready).waitFor();
|
||||
await noOverflow(page);
|
||||
}
|
||||
// Navigation may also cancel the shell's concurrent identity/access
|
||||
// reads. Only an explicit browser cancellation of these reads is expected.
|
||||
// 遷移でidentity/settingsの読み取りや遅延font取得がbrowserに取消されることはある。
|
||||
// 通信失敗一般を許すのではなく、これらの明示的な取消だけを区別する。
|
||||
assert(
|
||||
failedRequests.every(({ url, error }) =>
|
||||
error === "net::ERR_ABORTED" && [
|
||||
error === "net::ERR_ABORTED" && ([
|
||||
"/api/hosting/settings",
|
||||
"/api/auth/registration-policy",
|
||||
"/api/auth/whoami",
|
||||
"/api/hosting/access",
|
||||
].some((path) => url.endsWith(path))
|
||||
].some((path) => url.endsWith(path)) ||
|
||||
/^\/_app\/immutable\/assets\/[^/]+\.woff2$/.test(
|
||||
new URL(url).pathname,
|
||||
))
|
||||
),
|
||||
JSON.stringify(failedRequests),
|
||||
);
|
||||
|
||||
@@ -29,7 +29,12 @@ export function setupProjectionResponse(
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: stages.get(mode) === "login" ? { ...user, auth_method: "browser_session" } : null,
|
||||
session: stages.get(mode) === "login"
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: stages.get(mode) === "login"
|
||||
? { ...user, auth_method: "browser_session" }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
@@ -54,7 +59,10 @@ export function setupProjectionResponse(
|
||||
return Response.json({ user });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json({ challenge_id: "fixture-login", public_key: { challenge: "dWk" } });
|
||||
return Response.json({
|
||||
challenge_id: "fixture-login",
|
||||
public_key: { challenge: "dWk" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
stages.set(mode, "login");
|
||||
|
||||
@@ -68,6 +68,7 @@ async function loadPageWithFailure(failedSuffix: string): Promise<LoadedPage> {
|
||||
};
|
||||
const envelope = loadRepositoryAccessPage(
|
||||
{
|
||||
parent: async () => ({}),
|
||||
fetch: fetcher as typeof fetch,
|
||||
params: { workspaceId: "workspace-1" },
|
||||
url: new URL(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { assertEquals } from "jsr:@std/assert";
|
||||
import { browserSession } from "../src/lib/workspace/auth/session-state.ts";
|
||||
import { load, ssr } from "../src/routes/+layout.ts";
|
||||
|
||||
Deno.test("authentication and hosting shells never require Workspace catalog authority", async () => {
|
||||
@@ -16,22 +17,88 @@ Deno.test("authentication and hosting shells never require Workspace catalog aut
|
||||
"/hosting/setup",
|
||||
]
|
||||
) {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
Response.json({
|
||||
actor: {
|
||||
user_id: "u",
|
||||
account_id: "a",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
});
|
||||
browserSession.invalidate();
|
||||
let calls = 0;
|
||||
const event = {
|
||||
depends: () => {},
|
||||
url: new URL(path, "https://workspace.test"),
|
||||
fetch: () => {
|
||||
calls++;
|
||||
throw new Error("Workspace API must not be called");
|
||||
},
|
||||
} as unknown as Parameters<typeof load>[0];
|
||||
assertEquals(await load(event).deferredCatalog, {
|
||||
assertEquals(await (await load(event)).deferredCatalog, {
|
||||
ok: true,
|
||||
data: { accessibleWorkspaces: [], workspaceCatalogError: null },
|
||||
});
|
||||
assertEquals(calls, 0);
|
||||
browserSession.dispose();
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("workspace frontend serves deep links through the client SPA", () => {
|
||||
assertEquals(ssr, false);
|
||||
});
|
||||
|
||||
Deno.test("root admission never starts private catalog while anonymous, expired, loading or unknown", async () => {
|
||||
const original = globalThis.fetch;
|
||||
try {
|
||||
for (
|
||||
const condition of ["anonymous", "expired", "error", "loading"] as const
|
||||
) {
|
||||
browserSession.invalidate();
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
globalThis.fetch = async () => {
|
||||
if (condition === "loading") await pending;
|
||||
return condition === "error" || condition === "loading"
|
||||
? new Response(null, { status: 503 })
|
||||
: Response.json({ actor: null, session: { kind: condition } });
|
||||
};
|
||||
let privateCalls = 0;
|
||||
const event = {
|
||||
url: new URL("https://workspace.test/w/team/tickets?q=mine#entry"),
|
||||
depends: () => {},
|
||||
fetch: () => {
|
||||
++privateCalls;
|
||||
throw new Error("private fetch before admission");
|
||||
},
|
||||
} as unknown as Parameters<typeof load>[0];
|
||||
const result = load(event).then(() => null, (rejection) => rejection);
|
||||
await Promise.resolve();
|
||||
assertEquals(privateCalls, 0);
|
||||
release();
|
||||
const rejected = await result;
|
||||
assertEquals(
|
||||
rejected.status,
|
||||
condition === "anonymous" || condition === "expired" ? 307 : 503,
|
||||
);
|
||||
if (condition === "anonymous") assertEquals(rejected.location, "/");
|
||||
if (condition === "expired") {
|
||||
assertEquals(
|
||||
rejected.location,
|
||||
"/login?returnTo=%2Fw%2Fteam%2Ftickets%3Fq%3Dmine%23entry",
|
||||
);
|
||||
}
|
||||
assertEquals(privateCalls, 0);
|
||||
}
|
||||
} finally {
|
||||
browserSession.dispose();
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,6 +10,10 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
if (url.pathname === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
session:
|
||||
request.headers.get("cookie")?.includes("fixture-actor=authenticated")
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor:
|
||||
request.headers.get("cookie")?.includes("fixture-actor=authenticated")
|
||||
? {
|
||||
|
||||
@@ -11,8 +11,21 @@ import { dashboardFixture } from "../../src/lib/workspace/home/dashboard.test-fi
|
||||
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/api/auth/whoami") return Response.json({ actor: { user_id: "fixture-user", account_id: "fixture-owner", handle: "alice", display_name: "Alice", auth_method: "browser_session" } });
|
||||
if (url.pathname === "/api/hosting/access") return Response.json({ is_admin: false });
|
||||
if (url.pathname === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-owner",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: false });
|
||||
}
|
||||
if (url.pathname === "/api/workspaces" && request.method === "POST") {
|
||||
return Response.json({
|
||||
message:
|
||||
|
||||
Reference in New Issue
Block a user