import { beforeEach, describe, expect, it, vi } from 'vitest'; import { changePassword, clearLegacyStoredApiKey, getAuthConfig, getAuthSession, getMachineKey, login, logout, notifyUnauthorized, setup, subscribeUnauthorized } from './auth'; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); } describe('legacy API-key cleanup', () => { beforeEach(() => { window.localStorage.clear(); }); it('removes a leftover ctv-api-key entry', () => { window.localStorage.setItem('ctv-api-key', 'legacy-secret'); clearLegacyStoredApiKey(); expect(window.localStorage.getItem('ctv-api-key')).toBeNull(); }); it('is a no-op when localStorage is unavailable', () => { const localStorageGetter = vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { throw new Error('localStorage unavailable'); }); expect(() => clearLegacyStoredApiKey()).not.toThrow(); localStorageGetter.mockRestore(); }); }); describe('auth endpoints', () => { beforeEach(() => { vi.restoreAllMocks(); }); it('GET /api/v1/auth/config', async () => { const fetchMock = vi .spyOn(window, 'fetch') .mockResolvedValue(jsonResponse({ oidcEnabled: true, localLoginEnabled: true, setupRequired: false })); const config = await getAuthConfig(); expect(config).toEqual({ oidcEnabled: true, localLoginEnabled: true, setupRequired: false }); expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/config', expect.objectContaining({ method: 'GET' })); }); it('GET /api/v1/auth/session', async () => { const fetchMock = vi .spyOn(window, 'fetch') .mockResolvedValue(jsonResponse({ authenticated: true, username: 'admin', method: 'local' })); const session = await getAuthSession(); expect(session).toEqual({ authenticated: true, username: 'admin', method: 'local' }); expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/session', expect.objectContaining({ method: 'GET' })); }); it('GET /api/v1/auth/session for an anonymous caller (username/method omitted)', async () => { // The server serializes anonymous sessions as `{ "authenticated": false }` — username/method are // dropped by Newtonsoft's global NullValueHandling.Ignore, so AuthSession must treat them as // optional/undefined rather than present-but-null. vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ authenticated: false })); const session = await getAuthSession(); expect(session).toEqual({ authenticated: false }); expect(session.username).toBeUndefined(); expect(session.method).toBeUndefined(); }); it('POST /api/v1/auth/login with credentials in the body', async () => { const fetchMock = vi .spyOn(window, 'fetch') .mockResolvedValue(jsonResponse({ authenticated: true, username: 'admin', method: 'local' })); await login('admin', 'hunter2'); expect(fetchMock).toHaveBeenCalledWith( '/api/v1/auth/login', expect.objectContaining({ body: JSON.stringify({ username: 'admin', password: 'hunter2' }), method: 'POST' }) ); }); it('login does not trip the global 401 banner on a wrong-password 401', async () => { vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ status: 401, title: 'Unauthorized' }, 401)); const onUnauthorized = vi.fn(); const unsubscribe = subscribeUnauthorized(onUnauthorized); await expect(login('admin', 'wrong')).rejects.toMatchObject({ status: 401 }); expect(onUnauthorized).not.toHaveBeenCalled(); unsubscribe(); }); it('POST /api/v1/auth/setup with credentials in the body', async () => { const fetchMock = vi .spyOn(window, 'fetch') .mockResolvedValue(jsonResponse({ authenticated: true, username: 'admin', method: 'local' })); await setup('admin', 'hunter2'); expect(fetchMock).toHaveBeenCalledWith( '/api/v1/auth/setup', expect.objectContaining({ body: JSON.stringify({ username: 'admin', password: 'hunter2' }), method: 'POST' }) ); }); it('POST /api/v1/auth/logout', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); await logout(); expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/logout', expect.objectContaining({ method: 'POST' })); }); it('POST /api/v1/auth/password with both passwords, suppressing the 401 banner', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); await changePassword('old', 'new'); expect(fetchMock).toHaveBeenCalledWith( '/api/v1/auth/password', expect.objectContaining({ body: JSON.stringify({ currentPassword: 'old', newPassword: 'new' }), method: 'POST' }) ); }); it('GET /api/v1/auth/machine-key', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ apiKey: 'abc123' })); const result = await getMachineKey(); expect(result).toEqual({ apiKey: 'abc123' }); expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/machine-key', expect.objectContaining({ method: 'GET' })); }); }); 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); }); });