@biffo/cli 0.247.1 → 0.247.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it, vi, afterEach } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ApiError,
|
|
4
|
+
createApiClient,
|
|
5
|
+
extractErrorMessage,
|
|
6
|
+
__resetRenewedTokenCacheForTests,
|
|
7
|
+
} from './api-client'
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* A backend's error body is JSON like `{"detail": "..."}`. Throwing the whole
|
|
@@ -42,6 +47,7 @@ describe('extractErrorMessage', () => {
|
|
|
42
47
|
describe('createApiClient error handling', () => {
|
|
43
48
|
afterEach(() => {
|
|
44
49
|
vi.unstubAllGlobals()
|
|
50
|
+
__resetRenewedTokenCacheForTests()
|
|
45
51
|
})
|
|
46
52
|
|
|
47
53
|
it('throws an ApiError carrying the message, not the wire format', async () => {
|
|
@@ -83,6 +89,7 @@ describe('createApiClient session renewal', () => {
|
|
|
83
89
|
|
|
84
90
|
afterEach(() => {
|
|
85
91
|
vi.unstubAllGlobals()
|
|
92
|
+
__resetRenewedTokenCacheForTests()
|
|
86
93
|
})
|
|
87
94
|
|
|
88
95
|
function ok(body: unknown) {
|
|
@@ -117,6 +124,20 @@ describe('createApiClient session renewal', () => {
|
|
|
117
124
|
})
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Same shape as `fetchAcceptingOnly`, but for a scenario spanning more than
|
|
129
|
+
* one session's fresh token — proving a later session is never let in on an
|
|
130
|
+
* earlier session's renewed credential.
|
|
131
|
+
*/
|
|
132
|
+
function fetchAcceptingAnyOf(freshTokens: string[], body: unknown = { items: [] }) {
|
|
133
|
+
return vi.fn((_url: string, init: RequestInit) => {
|
|
134
|
+
const headers = (init.headers ?? {}) as Record<string, string>
|
|
135
|
+
const sent = headers['Authorization']
|
|
136
|
+
const accepted = freshTokens.some((token) => sent === `Bearer ${token}`)
|
|
137
|
+
return Promise.resolve(accepted ? ok(body) : unauthorized())
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
120
141
|
it('renews the session and retries once, so the call succeeds', async () => {
|
|
121
142
|
const fetchMock = fetchAcceptingOnly('fresh', { items: ['course'] })
|
|
122
143
|
vi.stubGlobal('fetch', fetchMock)
|
|
@@ -267,6 +288,89 @@ describe('createApiClient session renewal', () => {
|
|
|
267
288
|
expect(retry[1].body).toBe(JSON.stringify({ title: 'New' }))
|
|
268
289
|
})
|
|
269
290
|
|
|
291
|
+
/**
|
|
292
|
+
* The claim this PR (biffo-template#1283) exists to prove, not the retry
|
|
293
|
+
* behaviour #1277 already covers above. #1277 fixed the user-visible half —
|
|
294
|
+
* the FIRST 401 after expiry renews and retries so the call still succeeds.
|
|
295
|
+
* What it left is every LATER request on the same page: nothing wrote the
|
|
296
|
+
* renewed token back to the state `getIdToken()` reads, so a second request
|
|
297
|
+
* with the same stale closure paid the identical 401 + renew + retry cycle
|
|
298
|
+
* all over again, for the rest of the page's life. Without the fix in this
|
|
299
|
+
* PR, the second `api.get` below would fetch twice and renew a second time
|
|
300
|
+
* — exactly like the first request did — rather than sending the renewed
|
|
301
|
+
* token straight away.
|
|
302
|
+
*/
|
|
303
|
+
it('sends the renewed token directly on a later request, and does not 401 again', async () => {
|
|
304
|
+
const fetchMock = fetchAcceptingOnly('fresh')
|
|
305
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
306
|
+
const refresh = vi.fn(() => Promise.resolve<string | null>('fresh'))
|
|
307
|
+
|
|
308
|
+
// Every real caller passes `() => token` closing over React state that a
|
|
309
|
+
// renewal outside React never updates — so this closure keeps returning
|
|
310
|
+
// 'expired' for the rest of the page's life, exactly like the reporter's
|
|
311
|
+
// hours-long authoring session.
|
|
312
|
+
const api = createApiClient(() => 'expired', refresh)
|
|
313
|
+
|
|
314
|
+
// First request: pays the full cycle, exactly as #1277 fixed it.
|
|
315
|
+
await expect(api.get('/api/v1/courses')).resolves.toEqual({ items: [] })
|
|
316
|
+
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
317
|
+
expect(refresh).toHaveBeenCalledTimes(1)
|
|
318
|
+
|
|
319
|
+
// Second request: same client, same stale `getIdToken()` value. Assert
|
|
320
|
+
// the actual claim — exactly ONE fetch, carrying the renewed token
|
|
321
|
+
// directly, with no second 401 and no second renewal.
|
|
322
|
+
fetchMock.mockClear()
|
|
323
|
+
await expect(api.get('/api/v1/modules')).resolves.toEqual({ items: [] })
|
|
324
|
+
|
|
325
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
326
|
+
const [, init] = fetchMock.mock.calls[0]
|
|
327
|
+
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer fresh')
|
|
328
|
+
expect(refresh).toHaveBeenCalledTimes(1)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The hazard called out when this fix was scoped: a module-level cache is
|
|
333
|
+
* shared mutable state, so sign-out — or a different user signing in, in
|
|
334
|
+
* the same tab, which `tabsii-marketplace`'s public self-service flow makes
|
|
335
|
+
* real — must not let the previous session's renewed token leak into the
|
|
336
|
+
* next one. That would be a security defect, not a latency one.
|
|
337
|
+
*/
|
|
338
|
+
it('does not leak a renewed token across sign-out to a different session', async () => {
|
|
339
|
+
const fetchMock = fetchAcceptingAnyOf(['fresh-a', 'fresh-b'])
|
|
340
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
341
|
+
|
|
342
|
+
let currentToken = 'expired-a'
|
|
343
|
+
const refresh = vi.fn(() =>
|
|
344
|
+
Promise.resolve<string | null>(currentToken === 'expired-a' ? 'fresh-a' : 'fresh-b'),
|
|
345
|
+
)
|
|
346
|
+
const api = createApiClient(() => currentToken, refresh)
|
|
347
|
+
|
|
348
|
+
// User A's session: first call renews and retries; second call is served
|
|
349
|
+
// straight from the cache (this is the behaviour under test above).
|
|
350
|
+
await expect(api.get('/api/v1/courses')).resolves.toEqual({ items: [] })
|
|
351
|
+
await expect(api.get('/api/v1/courses')).resolves.toEqual({ items: [] })
|
|
352
|
+
expect(refresh).toHaveBeenCalledTimes(1)
|
|
353
|
+
|
|
354
|
+
// Sign-out, then a DIFFERENT user signs in — a brand new stale token from
|
|
355
|
+
// `getIdToken()`, simulating React state now belonging to a new session.
|
|
356
|
+
currentToken = 'expired-b'
|
|
357
|
+
const callsBeforeSwitch = fetchMock.mock.calls.length
|
|
358
|
+
|
|
359
|
+
await expect(api.get('/api/v1/courses')).resolves.toEqual({ items: [] })
|
|
360
|
+
|
|
361
|
+
const callsSinceSwitch = fetchMock.mock.calls.slice(callsBeforeSwitch)
|
|
362
|
+
// If user A's cached token had leaked, this request would succeed on its
|
|
363
|
+
// FIRST fetch by reusing 'fresh-a'. Instead it must pay its own renewal —
|
|
364
|
+
// proof the new session's first request sent its OWN stale token, not the
|
|
365
|
+
// previous session's cached fresh one.
|
|
366
|
+
expect(callsSinceSwitch).toHaveLength(2)
|
|
367
|
+
const firstAuthHeader = (callsSinceSwitch[0][1].headers as Record<string, string>)[
|
|
368
|
+
'Authorization'
|
|
369
|
+
]
|
|
370
|
+
expect(firstAuthHeader).toBe('Bearer expired-b')
|
|
371
|
+
expect(refresh).toHaveBeenCalledTimes(2)
|
|
372
|
+
})
|
|
373
|
+
|
|
270
374
|
it('leaves a non-401 failure entirely alone', async () => {
|
|
271
375
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
272
376
|
ok: false,
|
|
@@ -179,6 +179,51 @@ async function refreshViaPortalSession(): Promise<string | null> {
|
|
|
179
179
|
return session?.getIdToken().getJwtToken() ?? null
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* The freshest token this module has renewed a specific stale one into.
|
|
184
|
+
*
|
|
185
|
+
* Every caller passes `getIdToken` as `() => token` closing over React state
|
|
186
|
+
* that a renewal happening outside React never updates (see `send` below).
|
|
187
|
+
* Without this, the SAME stale value keeps coming back from `getIdToken()` for
|
|
188
|
+
* the rest of the page's life, so every request after the first expiry pays a
|
|
189
|
+
* full 401 + renew + retry cycle even though the previous renewal already
|
|
190
|
+
* produced a token that would have worked.
|
|
191
|
+
*
|
|
192
|
+
* Keyed by the stale token it replaced — not just "the freshest token" — and
|
|
193
|
+
* consulted in `send` only when the caller's own `getIdToken()` result still
|
|
194
|
+
* equals that key. That is what makes it safe to share at module scope:
|
|
195
|
+
*
|
|
196
|
+
* - **Safe across sign-out, or a different user signing in.** Either changes
|
|
197
|
+
* what `getIdToken()` returns, and a Cognito id token is unique per session,
|
|
198
|
+
* so the new value cannot equal a previous session's `staleToken`. The
|
|
199
|
+
* cache simply stops matching — nothing has to remember to clear it.
|
|
200
|
+
* Rejected: a bare "freshest known token" cache with no key would keep
|
|
201
|
+
* answering with the previous session's token until something explicitly
|
|
202
|
+
* cleared it on sign-out, which is a security defect (a stale credential
|
|
203
|
+
* handed to the wrong session) far worse than the latency this fixes.
|
|
204
|
+
* - **Safe for concurrent requests and multiple `createApiClient` instances.**
|
|
205
|
+
* They all read the same React state through their own `getIdToken`
|
|
206
|
+
* closures, so they all see the same stale value and all benefit from the
|
|
207
|
+
* one cache entry — there is nothing per-instance to keep in step, the same
|
|
208
|
+
* reasoning as `inFlightRenewal` above.
|
|
209
|
+
*
|
|
210
|
+
* Rejected alternative: an optional `onTokenRenewed` callback for callers to
|
|
211
|
+
* update their own state themselves. Cleaner ownership in principle, but it
|
|
212
|
+
* only helps callers that wire it up — every sibling's call sites today do
|
|
213
|
+
* not — so the default path would stay slow. This fixes the default path
|
|
214
|
+
* with no caller changes required.
|
|
215
|
+
*/
|
|
216
|
+
let renewedTokenCache: { staleToken: string; freshToken: string } | null = null
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Test-only: clear the module-scope renewal cache so one test's renewal
|
|
220
|
+
* cannot leak into the next. Production code never calls this — the cache is
|
|
221
|
+
* meant to live for the whole page.
|
|
222
|
+
*/
|
|
223
|
+
export function __resetRenewedTokenCacheForTests(): void {
|
|
224
|
+
renewedTokenCache = null
|
|
225
|
+
}
|
|
226
|
+
|
|
182
227
|
export function createApiClient(
|
|
183
228
|
getIdToken: () => string | null,
|
|
184
229
|
refreshIdToken: () => Promise<string | null> = refreshViaPortalSession,
|
|
@@ -197,13 +242,24 @@ export function createApiClient(
|
|
|
197
242
|
* makes "once" structural: there is no path back to the top, so a genuinely
|
|
198
243
|
* revoked session cannot spin however many 401s it produces.
|
|
199
244
|
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
245
|
+
* The first attempt prefers `renewedTokenCache` over `getIdToken()` when the
|
|
246
|
+
* caller's own state is still the exact value a previous renewal in this
|
|
247
|
+
* module already replaced — see the doc on `renewedTokenCache` above for why
|
|
248
|
+
* that is safe. That is what stops a page from paying the full cycle on
|
|
249
|
+
* every request for the rest of its life once React state has gone stale.
|
|
250
|
+
*
|
|
251
|
+
* The retry itself carries the token the renewal returned rather than
|
|
252
|
+
* re-reading `getIdToken()`. Every caller passes `() => token` closing over
|
|
253
|
+
* React state, which a renewal outside React has not updated — so re-reading
|
|
254
|
+
* it would re-send the credential the gateway just rejected and burn the one
|
|
255
|
+
* retry.
|
|
204
256
|
*/
|
|
205
257
|
async function send<T>(path: string, init: RequestInit): Promise<T> {
|
|
206
|
-
const
|
|
258
|
+
const callerToken = getIdToken()
|
|
259
|
+
const sentToken =
|
|
260
|
+
callerToken !== null && renewedTokenCache?.staleToken === callerToken
|
|
261
|
+
? renewedTokenCache.freshToken
|
|
262
|
+
: callerToken
|
|
207
263
|
const res = await fetch(`${API_URL}${path}`, { ...init, headers: authHeaders(sentToken) })
|
|
208
264
|
if (res.status !== 401) return handleResponse<T>(res)
|
|
209
265
|
|
|
@@ -214,6 +270,14 @@ export function createApiClient(
|
|
|
214
270
|
throw new ApiError(401, unauthorizedMessage(await bodyOf(res)))
|
|
215
271
|
}
|
|
216
272
|
|
|
273
|
+
// Cache against the caller's OWN stale value, not `sentToken` (which may
|
|
274
|
+
// already have been substituted from a previous cache hit) — that is the
|
|
275
|
+
// value `getIdToken()` keeps returning for the rest of the page's life, so
|
|
276
|
+
// it is the correct key for the next call to match against.
|
|
277
|
+
if (callerToken !== null) {
|
|
278
|
+
renewedTokenCache = { staleToken: callerToken, freshToken: renewed }
|
|
279
|
+
}
|
|
280
|
+
|
|
217
281
|
const retried = await fetch(`${API_URL}${path}`, { ...init, headers: authHeaders(renewed) })
|
|
218
282
|
if (retried.status === 401) {
|
|
219
283
|
throw new ApiError(401, unauthorizedMessage(await bodyOf(retried)))
|