Bundle A SPA slice: the /api surface is now gated behind X-Api-Key on every request (reads too, RequireKeyForReads defaults true), so a wrong/ missing key 401s everything. - #282: send X-Api-Key on ALL requests when a key is stored, not only mutations (removed the mutatingMethods split in api/client.ts). - #280: new keyless API Key screen (/app/api-key, System nav) that reads/ writes only localStorage via auth.ts and never calls /api, so it works on a fresh install where every read 401s. Masked key state, Save/Clear, points at server-generated /config/api.key. - 401 UX: client emits one app-wide unauthorized signal (auth.ts notify/subscribeUnauthorized); a shell-level UnauthorizedBanner points the user at the API Key screen. DRY, no per-screen 401 branches. - Tests: inverted the GET header assertion (key now sent on reads), added no-key and 401-signal client tests, auth signal tests, and screen + banner tests. spa-conventions.md §5e documents the new seams. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
57 lines
1.5 KiB
TypeScript
57 lines
1.5 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { clearStoredApiKey, getStoredApiKey, notifyUnauthorized, setStoredApiKey, subscribeUnauthorized } from './auth';
|
|
|
|
describe('API key storage', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
it('stores and reads the API key from local storage', () => {
|
|
setStoredApiKey('write-secret');
|
|
|
|
expect(getStoredApiKey()).toBe('write-secret');
|
|
expect(window.localStorage.getItem('ctv-api-key')).toBe('write-secret');
|
|
});
|
|
|
|
it('trims blank API keys and clears storage', () => {
|
|
setStoredApiKey(' ');
|
|
|
|
expect(getStoredApiKey()).toBeNull();
|
|
expect(window.localStorage.getItem('ctv-api-key')).toBeNull();
|
|
});
|
|
|
|
it('clears a stored API key', () => {
|
|
setStoredApiKey('write-secret');
|
|
|
|
clearStoredApiKey();
|
|
|
|
expect(getStoredApiKey()).toBeNull();
|
|
});
|
|
|
|
it('ignores unavailable local storage when reading the API key', () => {
|
|
const localStorageGetter = vi
|
|
.spyOn(window, 'localStorage', 'get')
|
|
.mockImplementation(() => {
|
|
throw new Error('localStorage unavailable');
|
|
});
|
|
|
|
expect(getStoredApiKey()).toBeNull();
|
|
|
|
localStorageGetter.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('unauthorized signal', () => {
|
|
it('notifies subscribers and stops after unsubscribe', () => {
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribeUnauthorized(listener);
|
|
|
|
notifyUnauthorized();
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
|
|
unsubscribe();
|
|
notifyUnauthorized();
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|