diff --git a/apps/pwa/src/api/client.test.ts b/apps/pwa/src/api/client.test.ts index 7bee75c..8b578e7 100644 --- a/apps/pwa/src/api/client.test.ts +++ b/apps/pwa/src/api/client.test.ts @@ -309,6 +309,74 @@ describe('fetchSyncStatus', () => { }) }) +// ── fetchMe tests ───────────────────────────────────────────────────────────── + +describe('fetchMe', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('requests /api/me with credentials:include AND redirect:manual', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + type: 'basic', + status: 200, + json: async () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }), + } as Response) + + const { fetchMe } = await import('./client.js') + await fetchMe() + + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + '/api/me', + expect.objectContaining({ credentials: 'include', redirect: 'manual' }), + ) + }) + + it('returns the user envelope on 200', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + type: 'basic', + status: 200, + json: async () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }), + } as Response) + + const { fetchMe } = await import('./client.js') + const result = await fetchMe() + expect(result).toEqual({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }) + }) + + it('throws on an opaqueredirect (OIDC guard 302 to Authelia) — the auth-required signal', async () => { + // redirect:'manual' surfaces a 302 as an opaqueredirect with status 0 and + // ok:false instead of following it (which would hang the fetch). + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + type: 'opaqueredirect', + status: 0, + json: async () => { + throw new Error('body not accessible on opaqueredirect') + }, + } as unknown as Response) + + const { fetchMe } = await import('./client.js') + await expect(fetchMe()).rejects.toThrow(/authentication required/i) + }) + + it('throws on a 401', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + type: 'basic', + status: 401, + } as Response) + + const { fetchMe } = await import('./client.js') + await expect(fetchMe()).rejects.toThrow(/authentication required/i) + }) +}) + // ── Zustand store new keys tests ────────────────────────────────────────────── describe('calendarStore — eventForm keys', () => { diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index cc7d73a..7a427ae 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -26,14 +26,26 @@ export interface MeResponse { } export async function fetchMe(): Promise { + // redirect: 'manual' is critical. The OIDC guard answers an unauthenticated + // request with a 302 to Authelia (cross-origin). With the default + // redirect: 'follow', the browser follows that credentialed cross-origin + // redirect and the fetch HANGS (never resolves, never rejects) — leaving the + // query stuck "loading" so the SPA spins forever and the auth-redirect below + // never fires. With 'manual', the 302 comes back as an opaqueredirect + // (res.type === 'opaqueredirect', res.status === 0) that we detect immediately. const res = await fetch('/api/me', { credentials: 'include', + redirect: 'manual', }) + if (res.type === 'opaqueredirect' || res.status === 401) { + // Session missing/expired → the guard wants us at Authelia. Signal the caller + // (CalendarShell) to perform a TOP-LEVEL navigation to /api/login via + // maybeRedirectToLogin() — a document navigation is not CORS-restricted. + throw new Error('GET /api/me: authentication required') + } + if (!res.ok) { - // Throw on any non-ok response. The OIDC guard's 302 to Authelia cannot be - // followed by fetch (CORS-blocked for XHR). Callers that handle auth errors - // should use maybeRedirectToLogin() for a top-level navigation to /api/login. throw new Error(`GET /api/me failed: ${res.status}`) }