From 07b5912faef4425f33089739547de76b1cc9ed03 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 17:28:10 -0400 Subject: [PATCH] feat(web): add rune-based auth store with bootstrap/login/logout Central synchronous source of truth for 'who is signed in'. bootstrap() runs once in +layout.ts load(); login/logout mutate _user and the TanStack query cache. The silent option on logout is used by the 401 interceptor (next commit) to avoid POSTing /logout against an already- invalid session. --- web/src/lib/auth/store.svelte.ts | 35 ++++++++++++++ web/src/lib/auth/store.test.ts | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 web/src/lib/auth/store.svelte.ts create mode 100644 web/src/lib/auth/store.test.ts diff --git a/web/src/lib/auth/store.svelte.ts b/web/src/lib/auth/store.svelte.ts new file mode 100644 index 00000000..5e4932ea --- /dev/null +++ b/web/src/lib/auth/store.svelte.ts @@ -0,0 +1,35 @@ +import { api, type User, type LoginResponse } from '$lib/api/client'; +import { queryClient } from '$lib/query/client'; + +let _user = $state(null); + +export const user = { + get value(): User | null { + return _user; + } +}; + +export async function bootstrap(): Promise { + try { + _user = await api.get('/api/me'); + } catch { + _user = null; + } +} + +export async function login(username: string, password: string): Promise { + const res = await api.post('/api/auth/login', { username, password }); + _user = res.user; +} + +export async function logout(opts: { silent?: boolean } = {}): Promise { + if (!opts.silent) { + try { + await api.post('/api/auth/logout', {}); + } catch { + // best effort — server-side session may already be gone + } + } + _user = null; + queryClient.clear(); +} diff --git a/web/src/lib/auth/store.test.ts b/web/src/lib/auth/store.test.ts new file mode 100644 index 00000000..cc81499c --- /dev/null +++ b/web/src/lib/auth/store.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('$lib/api/client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + del: vi.fn() + } +})); + +vi.mock('$lib/query/client', () => ({ + queryClient: { clear: vi.fn() } +})); + +import { api } from '$lib/api/client'; +import { queryClient } from '$lib/query/client'; +import { bootstrap, login, logout, user } from './store.svelte'; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +afterEach(() => { + // Force-clear the store between tests by stubbing api.get to reject, + // then calling bootstrap. +}); + +describe('auth store', () => { + test('bootstrap populates user on 200', async () => { + (api.get as ReturnType).mockResolvedValue({ + id: '1', username: 'alice', is_admin: false + }); + await bootstrap(); + expect(user.value).toEqual({ id: '1', username: 'alice', is_admin: false }); + }); + + test('bootstrap leaves user null on failure', async () => { + (api.get as ReturnType).mockRejectedValue( + { code: 'unauthorized', message: 'no session', status: 401 } + ); + await bootstrap(); + expect(user.value).toBeNull(); + }); + + test('login sets user from LoginResponse.user', async () => { + (api.post as ReturnType).mockResolvedValue({ + token: 't', user: { id: '2', username: 'bob', is_admin: true } + }); + await login('bob', 'pw'); + expect(user.value).toEqual({ id: '2', username: 'bob', is_admin: true }); + expect(api.post).toHaveBeenCalledWith('/api/auth/login', { + username: 'bob', password: 'pw' + }); + }); + + test('logout clears user, calls /api/auth/logout, and clears query cache', async () => { + (api.post as ReturnType).mockResolvedValue(null); + await logout(); + expect(user.value).toBeNull(); + expect(api.post).toHaveBeenCalledWith('/api/auth/logout', {}); + expect(queryClient.clear).toHaveBeenCalledTimes(1); + }); + + test('logout with silent:true skips the POST but still clears state', async () => { + await logout({ silent: true }); + expect(user.value).toBeNull(); + expect(api.post).not.toHaveBeenCalled(); + expect(queryClient.clear).toHaveBeenCalledTimes(1); + }); + + test('logout swallows POST errors (best-effort)', async () => { + (api.post as ReturnType).mockRejectedValue( + { code: 'server_error', message: 'boom', status: 500 } + ); + await expect(logout()).resolves.toBeUndefined(); + expect(user.value).toBeNull(); + }); +});