- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
162 lines
6.2 KiB
TypeScript
162 lines
6.2 KiB
TypeScript
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<Response>): 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<number | null> = []
|
|
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<number | null> = []
|
|
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)
|
|
})
|
|
})
|