@biffo/cli 0.274.0 → 0.275.0
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.
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { ApiError, createRequest } from './api-core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Guards for the distributed admin-API request core (biffo-template#1492),
|
|
6
|
+
* and in particular for the `onError` mapper added so the marketing plugin
|
|
7
|
+
* could adopt it without losing its endpoint-specific messages.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
11
|
+
return new Response(JSON.stringify(body), {
|
|
12
|
+
status,
|
|
13
|
+
headers: { 'Content-Type': 'application/json' },
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.unstubAllGlobals()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
function stubFetch(res: Response) {
|
|
22
|
+
const fetchMock = vi.fn().mockResolvedValue(res)
|
|
23
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
24
|
+
return fetchMock
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('createRequest without a mapper (the default path)', () => {
|
|
28
|
+
it('throws ApiError carrying the status and the response body', async () => {
|
|
29
|
+
stubFetch(new Response('you may not do that', { status: 403 }))
|
|
30
|
+
const request = createRequest(() => 'tok', '/base')
|
|
31
|
+
|
|
32
|
+
await expect(request('GET', '/thing')).rejects.toBeInstanceOf(ApiError)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('falls back to statusText when the body is empty', async () => {
|
|
36
|
+
stubFetch(new Response('', { status: 500, statusText: 'Internal Server Error' }))
|
|
37
|
+
const request = createRequest(() => 'tok', '/base')
|
|
38
|
+
|
|
39
|
+
await expect(request('GET', '/thing')).rejects.toThrow('Internal Server Error')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('createRequest with an onError mapper', () => {
|
|
44
|
+
/**
|
|
45
|
+
* THE POINT OF THE HOOK. The mapper must be able to read the JSON body,
|
|
46
|
+
* which means `request()` must not have consumed the stream before calling
|
|
47
|
+
* it. A mapper handed an already-read Response sees an empty body and falls
|
|
48
|
+
* back to a generic message — the silent degradation this exists to stop.
|
|
49
|
+
*/
|
|
50
|
+
it('hands the mapper a response whose body is still readable', async () => {
|
|
51
|
+
stubFetch(jsonResponse(403, { detail: 'you are not an admin of this brand' }))
|
|
52
|
+
const request = createRequest(
|
|
53
|
+
() => 'tok',
|
|
54
|
+
'/base',
|
|
55
|
+
async (res, context) => {
|
|
56
|
+
const body = (await res.json()) as { detail?: string }
|
|
57
|
+
throw new Error(`${body.detail} (${res.status}) while trying to ${context}`)
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
await expect(request('POST', '/links', {}, '/base', 'mint links')).rejects.toThrow(
|
|
62
|
+
'you are not an admin of this brand (403) while trying to mint links',
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('passes the per-call context through, and undefined when omitted', async () => {
|
|
67
|
+
const seen: (string | undefined)[] = []
|
|
68
|
+
stubFetch(jsonResponse(500, {}))
|
|
69
|
+
const request = createRequest(
|
|
70
|
+
() => 'tok',
|
|
71
|
+
'/base',
|
|
72
|
+
(_res, context) => {
|
|
73
|
+
seen.push(context)
|
|
74
|
+
throw new Error('mapped')
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
await expect(request('GET', '/a', undefined, '/base', 'load campaigns')).rejects.toThrow(
|
|
79
|
+
'mapped',
|
|
80
|
+
)
|
|
81
|
+
stubFetch(jsonResponse(500, {}))
|
|
82
|
+
await expect(request('GET', '/b')).rejects.toThrow('mapped')
|
|
83
|
+
|
|
84
|
+
expect(seen).toEqual(['load campaigns', undefined])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('never reaches the default ApiError once the mapper has thrown', async () => {
|
|
88
|
+
stubFetch(jsonResponse(403, { detail: 'nope' }))
|
|
89
|
+
const request = createRequest(
|
|
90
|
+
() => 'tok',
|
|
91
|
+
'/base',
|
|
92
|
+
() => {
|
|
93
|
+
throw new Error('mapped')
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
// If the default path still ran, this would be an ApiError instead.
|
|
98
|
+
await expect(request('GET', '/thing')).rejects.not.toBeInstanceOf(ApiError)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A mapper is typed `=> never`, but types are not enforcement at runtime and
|
|
103
|
+
* this is the dangerous failure: a mapper that returns normally would let
|
|
104
|
+
* `request()` fall through to the default throw. That is recoverable. What
|
|
105
|
+
* must never happen is a failed request RESOLVING — so this pins that even a
|
|
106
|
+
* misbehaving mapper cannot turn a 403 into a successful call.
|
|
107
|
+
*/
|
|
108
|
+
it('still throws if a mapper wrongly returns instead of throwing', async () => {
|
|
109
|
+
stubFetch(jsonResponse(403, { detail: 'nope' }))
|
|
110
|
+
const request = createRequest(() => 'tok', '/base', (() => undefined) as never)
|
|
111
|
+
|
|
112
|
+
await expect(request('GET', '/thing')).rejects.toBeInstanceOf(ApiError)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('does not run the mapper on a successful response', async () => {
|
|
116
|
+
stubFetch(jsonResponse(200, { ok: true }))
|
|
117
|
+
const onError = vi.fn(() => {
|
|
118
|
+
throw new Error('should not run')
|
|
119
|
+
})
|
|
120
|
+
const request = createRequest(() => 'tok', '/base', onError as never)
|
|
121
|
+
|
|
122
|
+
await expect(request('GET', '/thing')).resolves.toEqual({ ok: true })
|
|
123
|
+
expect(onError).not.toHaveBeenCalled()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -26,18 +26,48 @@ export class ApiError extends Error {
|
|
|
26
26
|
|
|
27
27
|
export type GetIdToken = () => string | null | Promise<string | null>
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Maps a failed response into the error a plugin wants to surface, INSTEAD of
|
|
31
|
+
* the default `ApiError(status, body)`.
|
|
32
|
+
*
|
|
33
|
+
* It receives the `Response` with its body **unread**, so it can `.json()` the
|
|
34
|
+
* payload — Core returns `{"detail": "..."}` for a permission failure, and a
|
|
35
|
+
* plugin usually wants that sentence rather than a status code. It also
|
|
36
|
+
* receives the per-call `context` string, because the useful wording is
|
|
37
|
+
* endpoint-specific: "you need the admin role to **mint links**" is actionable
|
|
38
|
+
* where "403" is not.
|
|
39
|
+
*
|
|
40
|
+
* Added for biffo-template#1492. The marketing plugin could not adopt this core
|
|
41
|
+
* without it: `createRequest` read the body into a string and threw immediately,
|
|
42
|
+
* leaving no seam to inspect it first, so migrating would have collapsed a dozen
|
|
43
|
+
* hand-written, endpoint-specific messages into one generic status string. That
|
|
44
|
+
* is a behaviour regression, and the plugin correctly refused rather than
|
|
45
|
+
* dropping them — the gap was in this module, not in the plugin.
|
|
46
|
+
*
|
|
47
|
+
* MUST NOT RETURN NORMALLY: it either throws, or returns a rejected promise.
|
|
48
|
+
* The `never` return type says so; a mapper that falls through would make
|
|
49
|
+
* `request()` resolve `undefined` for a failed call, which is the silent-success
|
|
50
|
+
* shape this estate spends most of its time eliminating. `assertThrew` in
|
|
51
|
+
* `api-core.test.ts` holds that line.
|
|
52
|
+
*/
|
|
53
|
+
export type ErrorMapper = (
|
|
54
|
+
response: Response,
|
|
55
|
+
context: string | undefined,
|
|
56
|
+
) => Promise<never> | never
|
|
57
|
+
|
|
29
58
|
/**
|
|
30
59
|
* Build a `request<T>(method, path, body?, base?)` function bound to a token
|
|
31
60
|
* source and a default base. A plugin's own api.ts calls this once per
|
|
32
61
|
* `createApi()` and defines its endpoints on top of the result — see the
|
|
33
62
|
* starter `api.ts` in this same directory for the worked shape.
|
|
34
63
|
*/
|
|
35
|
-
export function createRequest(getIdToken: GetIdToken, defaultBase: string) {
|
|
64
|
+
export function createRequest(getIdToken: GetIdToken, defaultBase: string, onError?: ErrorMapper) {
|
|
36
65
|
return async function request<T>(
|
|
37
66
|
method: string,
|
|
38
67
|
path: string,
|
|
39
68
|
body?: unknown,
|
|
40
69
|
base: string = defaultBase,
|
|
70
|
+
context?: string,
|
|
41
71
|
): Promise<T> {
|
|
42
72
|
const token = await getIdToken()
|
|
43
73
|
const res = await fetch(`${base}${path}`, {
|
|
@@ -49,6 +79,12 @@ export function createRequest(getIdToken: GetIdToken, defaultBase: string) {
|
|
|
49
79
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
50
80
|
})
|
|
51
81
|
if (!res.ok) {
|
|
82
|
+
// The mapper runs FIRST, and gets the response with its body still
|
|
83
|
+
// unread. Order is load-bearing: `res.text()` below consumes the stream,
|
|
84
|
+
// and a mapper handed an already-consumed Response cannot call `.json()`
|
|
85
|
+
// — it would silently see an empty body and fall back to a generic
|
|
86
|
+
// message, which is the exact failure this hook exists to prevent.
|
|
87
|
+
if (onError) await onError(res, context)
|
|
52
88
|
// Read the body for the reason: Core returns a JSON detail for a
|
|
53
89
|
// permission failure, and "403" alone tells an admin nothing about
|
|
54
90
|
// which rule bit.
|