import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { api, onRefreshed, onUnauthorized, refreshSession, REQUESTED_WITH } from '../../src/api/client' import { API, json, pathOf } from '../helpers' const unauthenticated = { error: { code: 'unauthenticated', message: 'Unauthenticated', details: {} } } const refreshed = (expiresIn: unknown) => json(200, { data: { token_type: 'cookie', expires_in: expiresIn }, meta: {} }) const navigation = { data: [], meta: {} } /** fetch mock driven by a handler; every request is recorded. */ function serve(handler: (request: Request) => Response | Promise): Request[] { const seen: Request[] = [] vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => { const request = input as Request seen.push(request) return handler(request) }) return seen } const count = (seen: Request[], path: string) => seen.filter((request) => pathOf(request) === `${API}${path}`).length beforeEach(() => { onUnauthorized(null) onRefreshed(null) }) afterEach(() => { onUnauthorized(null) onRefreshed(null) }) describe('transport', () => { it('sends X-Requested-With and same-origin credentials on every request to the runtime API base', async () => { const seen = serve(() => json(200, navigation)) await api.GET('/navigation') await api.POST('/auth/login', { body: { login: 'dev', password: 'x' } }) expect(REQUESTED_WITH).toBe('XMLHttpRequest') for (const request of seen) { expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest') expect(request.credentials).toBe('same-origin') expect(pathOf(request).startsWith(API)).toBe(true) } expect(seen.map(pathOf)).toEqual([`${API}/navigation`, `${API}/auth/login`]) }) it('passes a 401 from login or refresh through without refreshing', async () => { const seen = serve(() => json(401, unauthenticated)) const handler = vi.fn() onUnauthorized(handler) const login = await api.POST('/auth/login', { body: { login: 'dev', password: 'bad' } }) const refresh = await api.POST('/auth/refresh') expect(login.response.status).toBe(401) expect(refresh.response.status).toBe(401) expect(count(seen, '/auth/refresh')).toBe(1) expect(handler).not.toHaveBeenCalled() }) it('leaves other statuses alone', async () => { const seen = serve(() => json(403, { error: { code: 'forbidden', message: 'no', details: {} } })) const handler = vi.fn() onUnauthorized(handler) const result = await api.GET('/navigation') expect(result.response.status).toBe(403) expect(seen).toHaveLength(1) expect(handler).not.toHaveBeenCalled() }) it('single-flights one refresh for concurrent 401s and replays each request once with its body', async () => { let session = false const seen = serve(async (request) => { if (pathOf(request) === `${API}/auth/refresh`) { await new Promise((resolve) => setTimeout(resolve, 5)) session = true return refreshed(900) } if (!session) { return json(401, unauthenticated) } if (request.method === 'PUT') { return json(200, { data: await request.json(), meta: { labels: {} } }) } return json(200, navigation) }) const lifetimes: Array = [] onRefreshed((seconds) => lifetimes.push(seconds)) const handler = vi.fn() onUnauthorized(handler) const path = { vendor: 'acme', plugin: 'demo', controller: 'widgets', id: 1 } const [a, b, put] = await Promise.all([ api.GET('/navigation'), api.GET('/navigation'), api.PUT('/{vendor}/{plugin}/{controller}/{id}', { params: { path }, body: { name: 'Replayed' } }), ]) expect(a.response.status).toBe(200) expect(b.response.status).toBe(200) expect(put.data?.data).toEqual({ name: 'Replayed' }) expect(count(seen, '/auth/refresh')).toBe(1) expect(count(seen, '/navigation')).toBe(4) expect(count(seen, '/acme/demo/widgets/1')).toBe(2) expect(lifetimes).toEqual([900]) expect(handler).not.toHaveBeenCalled() }) it('reports the session gone when the refresh fails, without replaying', async () => { const seen = serve((request) => pathOf(request) === `${API}/auth/refresh` ? json(401, unauthenticated) : json(401, unauthenticated), ) const handler = vi.fn() onUnauthorized(handler) const result = await api.GET('/navigation') expect(result.response.status).toBe(401) expect(count(seen, '/navigation')).toBe(1) expect(handler).toHaveBeenCalledTimes(1) }) it('reports the session gone when the replay is still 401, and replays only once', async () => { const seen = serve((request) => (pathOf(request) === `${API}/auth/refresh` ? refreshed(60) : json(401, unauthenticated))) const handler = vi.fn() onUnauthorized(handler) const result = await api.GET('/navigation') expect(result.response.status).toBe(401) expect(count(seen, '/navigation')).toBe(2) expect(count(seen, '/auth/refresh')).toBe(1) expect(handler).toHaveBeenCalledTimes(1) }) }) describe('refreshSession', () => { it('shares one in-flight request between concurrent callers and starts a new one afterwards', async () => { const seen = serve(async () => { await new Promise((resolve) => setTimeout(resolve, 5)) return refreshed(120) }) const first = refreshSession() const second = refreshSession() expect(second).toBe(first) expect(await first).toBe(true) expect(count(seen, '/auth/refresh')).toBe(1) expect(await refreshSession()).toBe(true) expect(count(seen, '/auth/refresh')).toBe(2) }) it('reports a missing lifetime as null', async () => { serve(() => refreshed('soon')) const lifetimes: Array = [] onRefreshed((seconds) => lifetimes.push(seconds)) expect(await refreshSession()).toBe(true) expect(lifetimes).toEqual([null]) }) it('answers false on an error status or a network failure', async () => { serve(() => json(500, { error: { code: 'server', message: 'x', details: {} } })) expect(await refreshSession()).toBe(false) vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network')) expect(await refreshSession()).toBe(false) }) })