Files
galaxy-game/ui/frontend/tests/session-store.test.ts
Ilia Denisov 22b0710d04 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>
2026-05-07 15:24:21 +02:00

130 lines
4.3 KiB
TypeScript

// 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/);
});
});