phase 7: auth flow UI (email-code login + session resume + revocation)
Implements ui/PLAN.md Phase 7 end-to-end: - /login two-step form (email -> code) over the gateway public REST surface; /lobby placeholder issues the first authenticated user.account.get and renders the decoded display name. - SessionStore (Svelte 5 runes) with loading / unsupported / anonymous / authenticated states; layout-level route guard, browser-not-supported blocker, and a minimal SubscribeEvents revocation watcher that closes the active client within 1s on a clean stream end or Unauthenticated. - VITE_GATEWAY_BASE_URL + VITE_GATEWAY_RESPONSE_PUBLIC_KEY config plus AuthError taxonomy in api/auth.ts. - Vitest (auth-api, session-store, login-page) and Playwright e2e (auth-flow.spec.ts) on the four configured projects, with a fixture Ed25519 keypair forging Connect-Web JSON responses. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
// SessionStore unit tests under JSDOM with `fake-indexeddb` and Node
|
||||
// 22's WebCrypto. Each case wires a fresh `SessionStore` against a
|
||||
// per-test IndexedDB name, so persistence behaviour is observable
|
||||
// across cases without bleed and without touching the production
|
||||
// `dbConnection()` cache.
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
|
||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
||||
import type { Store } from "../src/platform/store/index";
|
||||
import { SessionStore } from "../src/lib/session-store.svelte";
|
||||
|
||||
let db: IDBPDatabase<GalaxyDB>;
|
||||
let dbName: string;
|
||||
let store: Store;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-ui-test-${crypto.randomUUID()}`;
|
||||
db = await openGalaxyDB(dbName);
|
||||
store = {
|
||||
keyStore: new WebCryptoKeyStore(db),
|
||||
cache: new IDBCache(db),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(dbName);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
function newSessionStore(): SessionStore {
|
||||
const s = new SessionStore();
|
||||
s.setStoreLoaderForTests(async () => store);
|
||||
return s;
|
||||
}
|
||||
|
||||
describe("SessionStore.init", () => {
|
||||
test("settles to anonymous when no device-session id is persisted", async () => {
|
||||
const session = newSessionStore();
|
||||
await session.init();
|
||||
expect(session.status).toBe("anonymous");
|
||||
expect(session.deviceSessionId).toBeNull();
|
||||
expect(session.keypair).not.toBeNull();
|
||||
expect(session.keypair!.publicKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test("settles to authenticated when a device-session id is persisted", async () => {
|
||||
const first = newSessionStore();
|
||||
await first.init();
|
||||
await first.signIn("dev-1");
|
||||
expect(first.status).toBe("authenticated");
|
||||
|
||||
// Simulate a fresh page load: a new SessionStore against the
|
||||
// same IndexedDB picks up the previously persisted session.
|
||||
const second = newSessionStore();
|
||||
await second.init();
|
||||
expect(second.status).toBe("authenticated");
|
||||
expect(second.deviceSessionId).toBe("dev-1");
|
||||
expect(second.keypair).not.toBeNull();
|
||||
});
|
||||
|
||||
test("flips status to unsupported when WebCrypto Ed25519 is missing", async () => {
|
||||
const session = newSessionStore();
|
||||
session.setSupportProbeForTests(async () => false);
|
||||
await session.init();
|
||||
expect(session.status).toBe("unsupported");
|
||||
expect(session.keypair).toBeNull();
|
||||
});
|
||||
|
||||
test("init is idempotent", async () => {
|
||||
const session = newSessionStore();
|
||||
await Promise.all([session.init(), session.init(), session.init()]);
|
||||
expect(session.status).toBe("anonymous");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionStore.signIn / signOut", () => {
|
||||
test("signIn persists the device-session id and updates status", async () => {
|
||||
const session = newSessionStore();
|
||||
await session.init();
|
||||
await session.signIn("dev-2");
|
||||
expect(session.deviceSessionId).toBe("dev-2");
|
||||
expect(session.status).toBe("authenticated");
|
||||
|
||||
const reload = newSessionStore();
|
||||
await reload.init();
|
||||
expect(reload.deviceSessionId).toBe("dev-2");
|
||||
});
|
||||
|
||||
test("signOut('user') wipes id, regenerates keypair, returns to anonymous", async () => {
|
||||
const session = newSessionStore();
|
||||
await session.init();
|
||||
const firstPublicKey = Array.from(session.keypair!.publicKey);
|
||||
await session.signIn("dev-3");
|
||||
await session.signOut("user");
|
||||
expect(session.deviceSessionId).toBeNull();
|
||||
expect(session.status).toBe("anonymous");
|
||||
expect(session.keypair).not.toBeNull();
|
||||
const secondPublicKey = Array.from(session.keypair!.publicKey);
|
||||
expect(secondPublicKey).not.toEqual(firstPublicKey);
|
||||
});
|
||||
|
||||
test("signOut('revoked') has the same observable post-state as 'user'", async () => {
|
||||
const session = newSessionStore();
|
||||
await session.init();
|
||||
await session.signIn("dev-4");
|
||||
await session.signOut("revoked");
|
||||
expect(session.status).toBe("anonymous");
|
||||
expect(session.deviceSessionId).toBeNull();
|
||||
});
|
||||
|
||||
test("signIn before init throws", async () => {
|
||||
const local = new SessionStore();
|
||||
await expect(local.signIn("x")).rejects.toThrow(/before init/);
|
||||
});
|
||||
|
||||
test("signOut before init throws", async () => {
|
||||
const local = new SessionStore();
|
||||
await expect(local.signOut("user")).rejects.toThrow(/before init/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user