fix(pwa): fetchMe uses redirect:manual so unauthenticated /api/me can't hang

With the default redirect:follow, the browser follows the OIDC guard's 302 to
Authelia (cross-origin, credentialed) and the fetch HANGS — meQuery stays
'loading' so the SPA spins forever and the isError-driven login redirect never
fires. redirect:manual surfaces the 302 as an opaqueredirect (status 0) that we
detect as auth-required and throw, letting CalendarShell navigate to /api/login.
+4 fetchMe tests.
This commit is contained in:
Lucas Berger
2026-06-06 21:57:42 -04:00
parent 874f23de2f
commit 1adb460412
2 changed files with 83 additions and 3 deletions
+68
View File
@@ -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', () => {