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:
@@ -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 ──────────────────────────────────────────────
|
// ── Zustand store new keys tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
describe('calendarStore — eventForm keys', () => {
|
describe('calendarStore — eventForm keys', () => {
|
||||||
|
|||||||
@@ -26,14 +26,26 @@ export interface MeResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMe(): Promise<MeResponse> {
|
export async function fetchMe(): Promise<MeResponse> {
|
||||||
|
// 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', {
|
const res = await fetch('/api/me', {
|
||||||
credentials: 'include',
|
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) {
|
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}`)
|
throw new Error(`GET /api/me failed: ${res.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user