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.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { api, type User, type LoginResponse } from '$lib/api/client';
|
||||
import { queryClient } from '$lib/query/client';
|
||||
|
||||
let _user = $state<User | null>(null);
|
||||
|
||||
export const user = {
|
||||
get value(): User | null {
|
||||
return _user;
|
||||
}
|
||||
};
|
||||
|
||||
export async function bootstrap(): Promise<void> {
|
||||
try {
|
||||
_user = await api.get<User>('/api/me');
|
||||
} catch {
|
||||
_user = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<void> {
|
||||
const res = await api.post<LoginResponse>('/api/auth/login', { username, password });
|
||||
_user = res.user;
|
||||
}
|
||||
|
||||
export async function logout(opts: { silent?: boolean } = {}): Promise<void> {
|
||||
if (!opts.silent) {
|
||||
try {
|
||||
await api.post('/api/auth/logout', {});
|
||||
} catch {
|
||||
// best effort — server-side session may already be gone
|
||||
}
|
||||
}
|
||||
_user = null;
|
||||
queryClient.clear();
|
||||
}
|
||||
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockRejectedValue(
|
||||
{ code: 'server_error', message: 'boom', status: 500 }
|
||||
);
|
||||
await expect(logout()).resolves.toBeUndefined();
|
||||
expect(user.value).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user