hanzio 3.0.0 → 3.1.1
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.
- package/README.md +114 -3
- package/dist/api-wrapper/connection-auth.d.ts +61 -0
- package/dist/cli/fields.d.ts +30 -0
- package/dist/cli/index.d.ts +122 -0
- package/dist/format/index.d.ts +91 -0
- package/dist/format/phone.d.ts +19 -0
- package/dist/http/index.d.ts +14 -0
- package/dist/i18n/catalog.d.ts +47 -0
- package/dist/i18n/check.d.ts +45 -0
- package/dist/i18n/cli.d.ts +2 -0
- package/dist/i18n/content-rules.d.ts +55 -0
- package/dist/i18n/i18next.d.ts +39 -0
- package/dist/i18n/index.d.ts +16 -1
- package/dist/i18n/locales.d.ts +62 -0
- package/dist/i18n/vite.d.ts +16 -0
- package/dist/i18n/vue.d.ts +93 -0
- package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
- package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
- package/dist/index-5n2e326q.js +409 -0
- package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
- package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
- package/dist/index-mq2qgc20.js +116 -0
- package/dist/index-najbyzpr.js +99 -0
- package/dist/index-nfvq3zax.js +475 -0
- package/dist/index-ww09bm49.js +250 -0
- package/dist/iso/countries.d.ts +262 -0
- package/dist/iso/currencies.d.ts +165 -0
- package/dist/iso/index.d.ts +4 -0
- package/dist/iso/languages.d.ts +195 -0
- package/dist/iso/phone-language.d.ts +5 -0
- package/dist/promise/index.d.ts +31 -0
- package/dist/redis/client.d.ts +49 -0
- package/dist/redis/concurrency-limit.d.ts +63 -0
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/lua-script.d.ts +13 -0
- package/dist/secrets/infisical-identity-file.d.ts +12 -0
- package/dist/src/api-wrapper/connection-auth.js +82 -0
- package/dist/src/api-wrapper/index.js +1 -1
- package/dist/src/cli/index.js +10 -0
- package/dist/src/format/index.js +79 -2
- package/dist/src/format/phone.js +21 -0
- package/dist/src/http/index.js +69 -0
- package/dist/src/i18n/check.js +15 -0
- package/dist/src/i18n/cli.js +71 -0
- package/dist/src/i18n/i18next.js +48 -0
- package/dist/src/i18n/index.js +16 -34
- package/dist/src/i18n/vite.js +25 -0
- package/dist/src/i18n/vue.js +51 -0
- package/dist/src/index.js +14 -4
- package/dist/src/iso/index.js +655 -0
- package/dist/src/redis/index.js +577 -0
- package/dist/src/secrets/index.js +78 -35
- package/dist/src/sitemap/index.js +1 -1
- package/dist/src/storage/index.js +8 -117
- package/dist/src/testing/index.js +348 -0
- package/dist/src/zod/index.js +20 -253
- package/dist/string/index.d.ts +21 -0
- package/dist/testing/connection-auth-contract.d.ts +43 -0
- package/dist/testing/helpers.d.ts +26 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/module-mocker.d.ts +30 -0
- package/dist/testing/prisma.d.ts +30 -0
- package/dist/testing/window.d.ts +18 -0
- package/dist/zod/index.d.ts +10 -0
- package/dist/zod/introspect.d.ts +2 -1
- package/package.json +96 -2
- package/src/api-wrapper/README.md +52 -0
- package/src/api-wrapper/connection-auth.ts +142 -0
- package/src/cli/README.md +220 -0
- package/src/cli/fields.ts +213 -0
- package/src/cli/index.ts +533 -0
- package/src/format/README.md +82 -2
- package/src/format/index.ts +148 -0
- package/src/format/phone.ts +43 -0
- package/src/http/README.md +25 -0
- package/src/http/index.ts +94 -0
- package/src/i18n/README.md +206 -3
- package/src/i18n/catalog.ts +139 -0
- package/src/i18n/check.ts +376 -0
- package/src/i18n/cli.ts +90 -0
- package/src/i18n/content-rules.ts +179 -0
- package/src/i18n/i18next.ts +89 -0
- package/src/i18n/index.ts +34 -0
- package/src/i18n/locales.ts +135 -0
- package/src/i18n/vite.ts +40 -0
- package/src/i18n/vue.ts +82 -0
- package/src/index.ts +1 -1
- package/src/iso/README.md +44 -0
- package/src/iso/countries.ts +272 -0
- package/src/iso/currencies.ts +172 -0
- package/src/iso/index.ts +8 -0
- package/src/iso/languages.ts +208 -0
- package/src/iso/phone-language.ts +46 -0
- package/src/promise/index.ts +100 -0
- package/src/redis/README.md +112 -0
- package/src/redis/client.ts +239 -0
- package/src/redis/concurrency-limit.ts +591 -0
- package/src/redis/index.ts +15 -0
- package/src/redis/lua-script.ts +43 -0
- package/src/secrets/README.md +5 -1
- package/src/secrets/infisical-identity-file.ts +69 -0
- package/src/secrets/infisical.ts +18 -3
- package/src/string/index.ts +59 -0
- package/src/testing/README.md +119 -0
- package/src/testing/connection-auth-contract.ts +303 -0
- package/src/testing/helpers.ts +41 -0
- package/src/testing/index.ts +12 -0
- package/src/testing/module-mocker.ts +50 -0
- package/src/testing/prisma.ts +78 -0
- package/src/testing/window.ts +119 -0
- package/src/zod/index.ts +20 -0
- package/src/zod/introspect.ts +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { EnvironmentVariables } from './environment'
|
|
3
|
+
import { SecretLoadError } from './errors'
|
|
4
|
+
|
|
5
|
+
const identityFileSchema = z.object({
|
|
6
|
+
clientId: z.string().min(1),
|
|
7
|
+
clientSecret: z.string().min(1),
|
|
8
|
+
siteUrl: z.string().url()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Machine identity (Universal Auth) credentials kept in a private file for every
|
|
13
|
+
* process on this machine, for example by agentgate, which syncs one identity to
|
|
14
|
+
* all of a user's machines. `INFISICAL_UNIVERSAL_AUTH_FILE` overrides the default
|
|
15
|
+
* `~/.infisical/universal-auth.json`. Only credentials for `siteUrl`'s instance are
|
|
16
|
+
* returned, so a secret is never sent to another Infisical instance.
|
|
17
|
+
*/
|
|
18
|
+
export async function readInfisicalIdentityFile(
|
|
19
|
+
env: EnvironmentVariables | undefined,
|
|
20
|
+
siteUrl: string
|
|
21
|
+
): Promise<{ clientId: string; clientSecret: string } | undefined> {
|
|
22
|
+
// Runtimes without a filesystem (browsers, workers) have no identity file.
|
|
23
|
+
const modules = await Promise.all([
|
|
24
|
+
import('node:fs/promises'),
|
|
25
|
+
import('node:os'),
|
|
26
|
+
import('node:path')
|
|
27
|
+
]).catch(() => undefined)
|
|
28
|
+
|
|
29
|
+
if (!modules) {
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const [fs, os, path] = modules
|
|
34
|
+
const file =
|
|
35
|
+
env?.INFISICAL_UNIVERSAL_AUTH_FILE ||
|
|
36
|
+
path.join(os.homedir(), '.infisical', 'universal-auth.json')
|
|
37
|
+
let text: string
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
text = await fs.readFile(file, 'utf8')
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if ((error as { code?: string }).code === 'ENOENT') {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
throw new SecretLoadError(
|
|
47
|
+
'CONFIGURATION',
|
|
48
|
+
`The Infisical identity file ${file} could not be read.`
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let identity: z.infer<typeof identityFileSchema>
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
identity = identityFileSchema.parse(JSON.parse(text))
|
|
56
|
+
} catch {
|
|
57
|
+
// Never echo the file: it holds a client secret.
|
|
58
|
+
throw new SecretLoadError(
|
|
59
|
+
'CONFIGURATION',
|
|
60
|
+
`The Infisical identity file ${file} is not valid; it needs clientId, clientSecret and siteUrl.`
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (new URL(identity.siteUrl).origin !== new URL(siteUrl).origin) {
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { clientId: identity.clientId, clientSecret: identity.clientSecret }
|
|
69
|
+
}
|
package/src/secrets/infisical.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { getProcessEnvironment } from './environment'
|
|
|
5
5
|
import { SecretLoadError } from './errors'
|
|
6
6
|
import { readInfisicalCliToken } from './infisical-cli'
|
|
7
7
|
import { validateInfisicalScope, type InfisicalScope } from './infisical-config'
|
|
8
|
+
import { readInfisicalIdentityFile } from './infisical-identity-file'
|
|
8
9
|
import { parseInfisicalSessionToken } from './infisical-session'
|
|
9
10
|
|
|
10
11
|
export const DEFAULT_INFISICAL_SITE_URL = 'https://eu.infisical.com'
|
|
@@ -74,14 +75,26 @@ export async function loadInfisicalSecrets<K extends string>(
|
|
|
74
75
|
validateInfisicalScope(options)
|
|
75
76
|
|
|
76
77
|
const env = getProcessEnvironment()
|
|
77
|
-
const clientId = env?.[options.clientIdEnvKey]
|
|
78
|
-
const clientSecret = env?.[options.clientSecretEnvKey]
|
|
79
78
|
const mode = options.auth ?? 'auto'
|
|
80
79
|
|
|
81
80
|
if (!['auto', 'http', 'cli'].includes(mode)) {
|
|
82
81
|
throw new SecretLoadError('CONFIGURATION', 'Invalid Infisical auth mode.')
|
|
83
82
|
}
|
|
84
83
|
|
|
84
|
+
// Environment credentials win; with neither set, a machine identity file
|
|
85
|
+
// stands in for them (see readInfisicalIdentityFile).
|
|
86
|
+
const fromEnv = Boolean(
|
|
87
|
+
env?.[options.clientIdEnvKey] || env?.[options.clientSecretEnvKey]
|
|
88
|
+
)
|
|
89
|
+
const file =
|
|
90
|
+
mode === 'cli' || fromEnv
|
|
91
|
+
? undefined
|
|
92
|
+
: await readInfisicalIdentityFile(env, options.siteUrl)
|
|
93
|
+
const clientId = fromEnv ? env?.[options.clientIdEnvKey] : file?.clientId
|
|
94
|
+
const clientSecret = fromEnv
|
|
95
|
+
? env?.[options.clientSecretEnvKey]
|
|
96
|
+
: file?.clientSecret
|
|
97
|
+
|
|
85
98
|
// Explicit modes take precedence. Auto mode uses HTTP only when both
|
|
86
99
|
// credentials exist; a failed HTTP request must not switch accounts.
|
|
87
100
|
const useHttp =
|
|
@@ -95,7 +108,9 @@ export async function loadInfisicalSecrets<K extends string>(
|
|
|
95
108
|
)
|
|
96
109
|
}
|
|
97
110
|
|
|
98
|
-
options.reportSource?.(
|
|
111
|
+
options.reportSource?.(
|
|
112
|
+
file ? 'Infisical HTTP (identity file)' : 'Infisical HTTP'
|
|
113
|
+
)
|
|
99
114
|
|
|
100
115
|
return fetchInfisicalSecrets({ ...options, clientId, clientSecret })
|
|
101
116
|
}
|
package/src/string/index.ts
CHANGED
|
@@ -322,3 +322,62 @@ function safeDecodeURIComponent(value: string): string {
|
|
|
322
322
|
return value
|
|
323
323
|
}
|
|
324
324
|
}
|
|
325
|
+
|
|
326
|
+
/** Escapes `& < > " '` for HTML or XML text and attribute values. */
|
|
327
|
+
export function escapeHtml(text: string): string {
|
|
328
|
+
return text
|
|
329
|
+
.replace(/&/g, '&')
|
|
330
|
+
.replace(/</g, '<')
|
|
331
|
+
.replace(/>/g, '>')
|
|
332
|
+
.replace(/"/g, '"')
|
|
333
|
+
.replace(/'/g, ''')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Pictographs plus the parts that build an emoji sequence: regional indicators
|
|
338
|
+
* (flags), skin tones, the keycap combiner, the emoji variation selector and
|
|
339
|
+
* the zero-width joiner. Digits, `#` and `*` also carry the `Emoji` property
|
|
340
|
+
* but are ordinary text, so they are deliberately not matched.
|
|
341
|
+
*/
|
|
342
|
+
const EMOJI_PATTERN =
|
|
343
|
+
/[\p{Extended_Pictographic}\u{1F1E6}-\u{1F1FF}\u{1F3FB}-\u{1F3FF}\u{20E3}\u{FE0F}\u{200D}]/gu
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Removes emoji and tidies the spacing they leave, so `'Hi there 👋, welcome'`
|
|
347
|
+
* becomes `'Hi there, welcome'`. Use it for text that is spoken (TTS drops
|
|
348
|
+
* emoji from the audio but not from the transcript). Text without emoji is
|
|
349
|
+
* returned unchanged.
|
|
350
|
+
*/
|
|
351
|
+
export function stripEmoji(text: string): string {
|
|
352
|
+
const stripped = text.replace(EMOJI_PATTERN, '')
|
|
353
|
+
if (stripped === text) return text
|
|
354
|
+
return stripped
|
|
355
|
+
.replace(/ {2,}/g, ' ')
|
|
356
|
+
.replace(/ +([,.!?;:])/g, '$1')
|
|
357
|
+
.trim()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Lowercased extension of a file name or path, without the dot. `undefined`
|
|
362
|
+
* when there is none; like `path.extname`, a leading dot (`.env`) is not one.
|
|
363
|
+
*/
|
|
364
|
+
export function fileExtension(filename: string): string | undefined {
|
|
365
|
+
const name = filename.slice(
|
|
366
|
+
Math.max(filename.lastIndexOf('/'), filename.lastIndexOf('\\')) + 1
|
|
367
|
+
)
|
|
368
|
+
const dot = name.lastIndexOf('.')
|
|
369
|
+
if (dot <= 0 || dot === name.length - 1) return undefined
|
|
370
|
+
return name.slice(dot + 1).toLowerCase()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Normalizes a value the way a JSON round trip does: `undefined` properties
|
|
375
|
+
* are dropped, `Date`s become ISO strings and `NaN` becomes `null`. Top-level
|
|
376
|
+
* `undefined` becomes `null`. Use it before storing loosely typed data in a
|
|
377
|
+
* JSON column. Throws on `bigint` and circular references.
|
|
378
|
+
*/
|
|
379
|
+
export function toJsonValue(value: unknown): JsonValue {
|
|
380
|
+
if (value === undefined) return null
|
|
381
|
+
const text = JSON.stringify(value)
|
|
382
|
+
return text === undefined ? null : (JSON.parse(text) as JsonValue)
|
|
383
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: bun:test helpers - restorable module mocks, a deep Prisma mock, a minimal window shim, and a race contract for API clients that share logins.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# hanzio/testing
|
|
6
|
+
|
|
7
|
+
For `bun:test`. The module can be imported outside Bun; `bun:test` is only
|
|
8
|
+
loaded when a helper that needs it runs.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import {
|
|
12
|
+
connectionAuthContract,
|
|
13
|
+
createPrismaMock,
|
|
14
|
+
installWindowShim,
|
|
15
|
+
ModuleMocker,
|
|
16
|
+
mockCallArg,
|
|
17
|
+
noopLogger
|
|
18
|
+
} from 'hanzio/testing'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Restorable module mocks
|
|
22
|
+
|
|
23
|
+
Bun's `mock.module` replaces a module for the whole test run, and
|
|
24
|
+
`mock.restore()` does not undo it, so a stub in one file leaks into every file
|
|
25
|
+
that runs after it. `ModuleMocker` spreads the real exports under your
|
|
26
|
+
overrides and `clear()` puts the originals back.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const moduleMocker = new ModuleMocker()
|
|
30
|
+
await moduleMocker.mock(path.resolve(import.meta.dir, '../lib/posthog.ts'), () => ({
|
|
31
|
+
captureException: mock()
|
|
32
|
+
}))
|
|
33
|
+
afterAll(() => moduleMocker.clear())
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Bun resolves a relative specifier from the file that registers the mock, which
|
|
37
|
+
would be hanzio's. Pass absolute paths or package names, or give the
|
|
38
|
+
constructor a resolver for your own shorthand:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const moduleMocker = new ModuleMocker((specifier) =>
|
|
42
|
+
specifier.startsWith('@/') ? path.resolve(srcDir, specifier.slice(2)) : specifier
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Prisma
|
|
47
|
+
|
|
48
|
+
`createPrismaMock<Client>(mock)` builds a client where every model method is a
|
|
49
|
+
Bun mock created on first access. Register it in place of the module that
|
|
50
|
+
exports your client, spreading that module's other exports so they keep
|
|
51
|
+
working for every file in the run:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { mock } from 'bun:test'
|
|
55
|
+
import * as prismaClientExports from '@prisma/client'
|
|
56
|
+
import type { prisma } from '../lib/prisma'
|
|
57
|
+
|
|
58
|
+
export const prismaMock = createPrismaMock<typeof prisma>(mock)
|
|
59
|
+
mock.module(path.resolve(import.meta.dir, '../lib/prisma/index.ts'), () => ({
|
|
60
|
+
...prismaClientExports,
|
|
61
|
+
prisma: prismaMock
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
beforeEach(() => prismaMock.$reset())
|
|
65
|
+
prismaMock.user.findUnique.mockResolvedValue({ id: 'u1' })
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Model and method names are checked against the client type; arguments and
|
|
69
|
+
return values are not.
|
|
70
|
+
|
|
71
|
+
## Window shim
|
|
72
|
+
|
|
73
|
+
`installWindowShim(href?)` installs a minimal `window` (`location`,
|
|
74
|
+
`localStorage`, no-op event listeners) for code that only probes for one, and
|
|
75
|
+
returns a reset for `afterEach`. Tests may assign a partial `window`; the shim
|
|
76
|
+
keeps the `location`, `localStorage` and listeners from it, because libraries
|
|
77
|
+
such as axios and PostHog read them at import time. It deliberately installs no
|
|
78
|
+
`document`, which Vue and PrismJS would treat as a full DOM.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// test-setup.ts, preloaded through bunfig.toml
|
|
82
|
+
afterEach(installWindowShim())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`createTestStorage()` is the in-memory `Storage` it uses.
|
|
86
|
+
|
|
87
|
+
## Connection auth contract
|
|
88
|
+
|
|
89
|
+
`connectionAuthContract` registers the race tests every API client built on
|
|
90
|
+
`ConnectionAuthCache` ([`hanzio/api-wrapper/connection-auth`](../api-wrapper/README.md#sharing-a-login-across-clients))
|
|
91
|
+
must pass, run through the real client and transport: shared refreshes,
|
|
92
|
+
rotated credentials, late 401s, aborted waiters and failed fresh logins.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
await connectionAuthContract({
|
|
96
|
+
reset: () => authCache.clear(),
|
|
97
|
+
create: (fetch, variant) => new ShopApi(credentialsFor(variant), fetch),
|
|
98
|
+
identityChanges: ['apiKey', 'secret'],
|
|
99
|
+
isLogin: (url) => url.endsWith('/oauth/token'),
|
|
100
|
+
loginResponse: (token) => Response.json({ access_token: token, expires_in: 3600 }),
|
|
101
|
+
readResponse: () => Response.json({ items: [] }),
|
|
102
|
+
tokenHeader: 'authorization'
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`create(fetch, variant)` builds a client. `variant` is one of
|
|
107
|
+
`identityChanges` (must force a new login), `'settings'` (must reuse it),
|
|
108
|
+
`'connection'` (another connection) or `'secret'` (a rotated secret). `read()`
|
|
109
|
+
makes one request and, on a 401, calls `reject` and retries once; `login()`
|
|
110
|
+
forces a fresh login.
|
|
111
|
+
|
|
112
|
+
## Small helpers
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const input = mockCallArg<CreateUserInput>(createUser) // call 0, argument 0
|
|
116
|
+
mockCallArg<boolean>(createUser, 1, 1) // call 1, argument 1
|
|
117
|
+
|
|
118
|
+
runJob({ logger: noopLogger }) // trace…fatal discard; bind/child return itself
|
|
119
|
+
```
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Bun types `clone()` as the undici Response; it is the same runtime object.
|
|
2
|
+
const cloneResponse = (response: Response) => response.clone() as Response
|
|
3
|
+
|
|
4
|
+
export type ConnectionAuthContractInput = {
|
|
5
|
+
reset: () => void
|
|
6
|
+
create: (
|
|
7
|
+
fetchImpl: typeof fetch,
|
|
8
|
+
variant?: string
|
|
9
|
+
) => {
|
|
10
|
+
read: () => Promise<unknown>
|
|
11
|
+
login: (options?: { signal?: AbortSignal }) => Promise<string>
|
|
12
|
+
}
|
|
13
|
+
identityChanges: string[]
|
|
14
|
+
isLogin: (url: string) => boolean
|
|
15
|
+
loginResponse: (token: string) => Response
|
|
16
|
+
readResponse: () => Response
|
|
17
|
+
tokenHeader: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Registers the races every client built on `ConnectionAuthCache`
|
|
22
|
+
* (`hanzio/api-wrapper/connection-auth`) must survive, run through the real
|
|
23
|
+
* client and its transport: shared refreshes, rotated credentials, late 401s,
|
|
24
|
+
* aborted waiters and failed fresh logins. Await it at the top level of a
|
|
25
|
+
* `bun:test` file.
|
|
26
|
+
*
|
|
27
|
+
* - `create(fetch, variant)` builds a client on the given fetch. `variant` is
|
|
28
|
+
* one of `identityChanges` (a credential change that must force a new login),
|
|
29
|
+
* `'settings'` (a non-auth setting that must reuse the login), `'connection'`
|
|
30
|
+
* (another connection) or `'secret'` (a rotated secret).
|
|
31
|
+
* - `read()` makes one authenticated request; on a 401 it calls `reject` and
|
|
32
|
+
* retries once. `login()` forces a fresh login and resolves its token.
|
|
33
|
+
* - `reset()` clears the cache between tests.
|
|
34
|
+
* - `tokenHeader` is where requests carry the token; `'authorization'` means
|
|
35
|
+
* `Bearer <token>`.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* await connectionAuthContract({
|
|
39
|
+
* reset: () => authCache.clear(),
|
|
40
|
+
* create: (fetch, variant) => new ShopApi(credentialsFor(variant), fetch),
|
|
41
|
+
* identityChanges: ['apiKey', 'secret'],
|
|
42
|
+
* isLogin: (url) => url.endsWith('/oauth/token'),
|
|
43
|
+
* loginResponse: (token) => Response.json({ access_token: token, expires_in: 3600 }),
|
|
44
|
+
* readResponse: () => Response.json({ items: [] }),
|
|
45
|
+
* tokenHeader: 'authorization'
|
|
46
|
+
* })
|
|
47
|
+
*/
|
|
48
|
+
export async function connectionAuthContract(
|
|
49
|
+
input: ConnectionAuthContractInput
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const { beforeEach, describe, expect, test } = await import('bun:test')
|
|
52
|
+
describe('shared authentication contract', () => {
|
|
53
|
+
beforeEach(input.reset)
|
|
54
|
+
|
|
55
|
+
function provider(
|
|
56
|
+
respond: (
|
|
57
|
+
login: boolean,
|
|
58
|
+
init: RequestInit
|
|
59
|
+
) => Promise<Response> | Response
|
|
60
|
+
) {
|
|
61
|
+
let exchanges = 0
|
|
62
|
+
const usedTokens: (string | null)[] = []
|
|
63
|
+
const fetchImpl = (async (url, init) => {
|
|
64
|
+
const login = input.isLogin(String(url))
|
|
65
|
+
if (login) exchanges += 1
|
|
66
|
+
else usedTokens.push(new Headers(init?.headers).get(input.tokenHeader))
|
|
67
|
+
return respond(login, init ?? {})
|
|
68
|
+
}) as typeof fetch
|
|
69
|
+
|
|
70
|
+
return { fetchImpl, usedTokens, exchanges: () => exchanges }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test.each(
|
|
74
|
+
input.identityChanges
|
|
75
|
+
)('%s changes authentication identity', async (variant) => {
|
|
76
|
+
const fake = provider((login) =>
|
|
77
|
+
login
|
|
78
|
+
? input.loginResponse(`token-${fake.exchanges()}`)
|
|
79
|
+
: input.readResponse()
|
|
80
|
+
)
|
|
81
|
+
await input.create(fake.fetchImpl).read()
|
|
82
|
+
await input.create(fake.fetchImpl, variant).read()
|
|
83
|
+
|
|
84
|
+
expect(fake.exchanges()).toBe(2)
|
|
85
|
+
expect(fake.usedTokens[1]).not.toBe(fake.usedTokens[0])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('non-auth settings reuse completed authentication across instances', async () => {
|
|
89
|
+
const fake = provider((login) =>
|
|
90
|
+
login ? input.loginResponse('same') : input.readResponse()
|
|
91
|
+
)
|
|
92
|
+
await input.create(fake.fetchImpl).read()
|
|
93
|
+
await input.create(fake.fetchImpl, 'settings').read()
|
|
94
|
+
|
|
95
|
+
expect(fake.exchanges()).toBe(1)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('unrelated connections proceed while another login is blocked', async () => {
|
|
99
|
+
const pending = Promise.withResolvers<Response>()
|
|
100
|
+
const fake = provider((login) => {
|
|
101
|
+
if (!login) return input.readResponse()
|
|
102
|
+
return fake.exchanges() === 1
|
|
103
|
+
? pending.promise
|
|
104
|
+
: input.loginResponse('independent')
|
|
105
|
+
})
|
|
106
|
+
const blocked = input.create(fake.fetchImpl).read()
|
|
107
|
+
await input.create(fake.fetchImpl, 'connection').read()
|
|
108
|
+
expect(fake.exchanges()).toBe(2)
|
|
109
|
+
|
|
110
|
+
pending.resolve(input.loginResponse('blocked'))
|
|
111
|
+
await blocked
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('failed shared refresh is evicted and a later attempt recovers', async () => {
|
|
115
|
+
const pending = Promise.withResolvers<Response>()
|
|
116
|
+
const fake = provider((login) =>
|
|
117
|
+
login ? pending.promise.then(cloneResponse) : input.readResponse()
|
|
118
|
+
)
|
|
119
|
+
const results = Promise.allSettled(
|
|
120
|
+
Array.from({ length: 3 }, () => input.create(fake.fetchImpl).read())
|
|
121
|
+
)
|
|
122
|
+
pending.reject(new Error('provider unavailable'))
|
|
123
|
+
expect((await results).map((result) => result.status)).toEqual([
|
|
124
|
+
'rejected',
|
|
125
|
+
'rejected',
|
|
126
|
+
'rejected'
|
|
127
|
+
])
|
|
128
|
+
expect(fake.exchanges()).toBe(1)
|
|
129
|
+
|
|
130
|
+
const recovered = provider((login) =>
|
|
131
|
+
login ? input.loginResponse('recovered') : input.readResponse()
|
|
132
|
+
)
|
|
133
|
+
await input.create(recovered.fetchImpl).read()
|
|
134
|
+
expect(recovered.exchanges()).toBe(1)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('simultaneous 401s share one refresh, and a delayed 401 cannot evict it', async () => {
|
|
138
|
+
const rejected = Array.from({ length: 3 }, () =>
|
|
139
|
+
Promise.withResolvers<Response>()
|
|
140
|
+
)
|
|
141
|
+
const requestsStarted = Promise.withResolvers<void>()
|
|
142
|
+
const refresh = Promise.withResolvers<Response>()
|
|
143
|
+
let oldRequests = 0
|
|
144
|
+
const fake = provider((login, init) => {
|
|
145
|
+
if (login)
|
|
146
|
+
return fake.exchanges() === 1
|
|
147
|
+
? input.loginResponse('A')
|
|
148
|
+
: refresh.promise.then(cloneResponse)
|
|
149
|
+
const header = new Headers(init.headers).get(input.tokenHeader)
|
|
150
|
+
if (
|
|
151
|
+
header === (input.tokenHeader === 'authorization' ? 'Bearer A' : 'A')
|
|
152
|
+
) {
|
|
153
|
+
const response = rejected[oldRequests++]!
|
|
154
|
+
if (oldRequests === 3) requestsStarted.resolve()
|
|
155
|
+
return response.promise
|
|
156
|
+
}
|
|
157
|
+
return input.readResponse()
|
|
158
|
+
})
|
|
159
|
+
await input.create(fake.fetchImpl).login()
|
|
160
|
+
const reads = Array.from({ length: 3 }, () =>
|
|
161
|
+
input.create(fake.fetchImpl).read()
|
|
162
|
+
)
|
|
163
|
+
await requestsStarted.promise
|
|
164
|
+
|
|
165
|
+
rejected[0]!.resolve(new Response('', { status: 401 }))
|
|
166
|
+
rejected[1]!.resolve(new Response('', { status: 401 }))
|
|
167
|
+
refresh.resolve(input.loginResponse('B'))
|
|
168
|
+
await Promise.all(reads.slice(0, 2))
|
|
169
|
+
rejected[2]!.resolve(new Response('', { status: 401 }))
|
|
170
|
+
await reads[2]
|
|
171
|
+
|
|
172
|
+
expect(fake.exchanges()).toBe(2)
|
|
173
|
+
expect(fake.usedTokens).toHaveLength(6)
|
|
174
|
+
expect(fake.usedTokens.slice(3)).toEqual(
|
|
175
|
+
Array(3).fill(input.tokenHeader === 'authorization' ? 'Bearer B' : 'B')
|
|
176
|
+
)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('a delayed 401 cannot evict a new generation with identical token bytes', async () => {
|
|
180
|
+
const rejected = Promise.withResolvers<Response>()
|
|
181
|
+
const started = Promise.withResolvers<void>()
|
|
182
|
+
let reads = 0
|
|
183
|
+
const fake = provider((login) => {
|
|
184
|
+
if (login) return input.loginResponse('same')
|
|
185
|
+
reads += 1
|
|
186
|
+
if (reads === 1) {
|
|
187
|
+
started.resolve()
|
|
188
|
+
return rejected.promise
|
|
189
|
+
}
|
|
190
|
+
return input.readResponse()
|
|
191
|
+
})
|
|
192
|
+
await input.create(fake.fetchImpl).login()
|
|
193
|
+
const oldRead = input.create(fake.fetchImpl).read()
|
|
194
|
+
await started.promise
|
|
195
|
+
await input.create(fake.fetchImpl).login()
|
|
196
|
+
rejected.resolve(new Response('', { status: 401 }))
|
|
197
|
+
await oldRead
|
|
198
|
+
|
|
199
|
+
expect(fake.exchanges()).toBe(2)
|
|
200
|
+
expect(reads).toBe(2)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('failed fresh authentication cannot fall back to the previous completed token', async () => {
|
|
204
|
+
const fake = provider((login) => {
|
|
205
|
+
if (!login) return input.readResponse()
|
|
206
|
+
if (fake.exchanges() === 2) throw new Error('credentials rejected')
|
|
207
|
+
return input.loginResponse('valid')
|
|
208
|
+
})
|
|
209
|
+
await input.create(fake.fetchImpl).read()
|
|
210
|
+
await expect(input.create(fake.fetchImpl).login()).rejects.toThrow(
|
|
211
|
+
'credentials rejected'
|
|
212
|
+
)
|
|
213
|
+
await input.create(fake.fetchImpl).read()
|
|
214
|
+
|
|
215
|
+
expect(fake.exchanges()).toBe(3)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('a late old login cannot replace rotated authentication', async () => {
|
|
219
|
+
const pending = Promise.withResolvers<Response>()
|
|
220
|
+
const fake = provider((login) => {
|
|
221
|
+
if (!login) return input.readResponse()
|
|
222
|
+
return fake.exchanges() === 1
|
|
223
|
+
? pending.promise
|
|
224
|
+
: input.loginResponse('new')
|
|
225
|
+
})
|
|
226
|
+
const old = input.create(fake.fetchImpl).read()
|
|
227
|
+
await input.create(fake.fetchImpl, 'secret').read()
|
|
228
|
+
pending.resolve(input.loginResponse('old'))
|
|
229
|
+
await old
|
|
230
|
+
await input.create(fake.fetchImpl, 'secret').read()
|
|
231
|
+
|
|
232
|
+
expect(fake.exchanges()).toBe(2)
|
|
233
|
+
expect(fake.usedTokens.at(-1)).toBe(
|
|
234
|
+
input.tokenHeader === 'authorization' ? 'Bearer new' : 'new'
|
|
235
|
+
)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('fresh logins bypass completed tokens and coalesce identical pending checks', async () => {
|
|
239
|
+
const pending = Promise.withResolvers<Response>()
|
|
240
|
+
const fake = provider((login) => {
|
|
241
|
+
if (!login) return input.readResponse()
|
|
242
|
+
return fake.exchanges() === 1
|
|
243
|
+
? input.loginResponse('warm')
|
|
244
|
+
: pending.promise.then(cloneResponse)
|
|
245
|
+
})
|
|
246
|
+
await input.create(fake.fetchImpl).read()
|
|
247
|
+
const checks = Array.from({ length: 3 }, () =>
|
|
248
|
+
input.create(fake.fetchImpl).login()
|
|
249
|
+
)
|
|
250
|
+
expect(fake.exchanges()).toBe(2)
|
|
251
|
+
pending.resolve(input.loginResponse('fresh'))
|
|
252
|
+
expect(await Promise.all(checks)).toEqual(['fresh', 'fresh', 'fresh'])
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('aborting the initiating waiter leaves the shared refresh running', async () => {
|
|
256
|
+
const pending = Promise.withResolvers<Response>()
|
|
257
|
+
const fake = provider((login, init) => {
|
|
258
|
+
if (!login) return input.readResponse()
|
|
259
|
+
expect(init.signal).toBeUndefined()
|
|
260
|
+
return pending.promise
|
|
261
|
+
})
|
|
262
|
+
const controller = new AbortController()
|
|
263
|
+
const cancelled = input
|
|
264
|
+
.create(fake.fetchImpl)
|
|
265
|
+
.login({ signal: controller.signal })
|
|
266
|
+
const survivor = input.create(fake.fetchImpl).read()
|
|
267
|
+
controller.abort(new Error('wait cancelled'))
|
|
268
|
+
await expect(cancelled).rejects.toThrow('wait cancelled')
|
|
269
|
+
|
|
270
|
+
pending.resolve(input.loginResponse('survived'))
|
|
271
|
+
await survivor
|
|
272
|
+
await input.create(fake.fetchImpl).read()
|
|
273
|
+
expect(fake.exchanges()).toBe(1)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('a pre-aborted waiter starts no exchange', async () => {
|
|
277
|
+
const fake = provider(() => {
|
|
278
|
+
throw new Error('unexpected fetch')
|
|
279
|
+
})
|
|
280
|
+
const controller = new AbortController()
|
|
281
|
+
controller.abort(new Error('already cancelled'))
|
|
282
|
+
|
|
283
|
+
await expect(
|
|
284
|
+
input.create(fake.fetchImpl).login({ signal: controller.signal })
|
|
285
|
+
).rejects.toThrow('already cancelled')
|
|
286
|
+
expect(fake.exchanges()).toBe(0)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('a second 401 is surfaced without expanding the retry budget', async () => {
|
|
290
|
+
const fake = provider((login) =>
|
|
291
|
+
login
|
|
292
|
+
? input.loginResponse('rejected')
|
|
293
|
+
: new Response('', { status: 401 })
|
|
294
|
+
)
|
|
295
|
+
await expect(input.create(fake.fetchImpl).read()).rejects.toMatchObject({
|
|
296
|
+
status: 401
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
expect(fake.exchanges()).toBe(2)
|
|
300
|
+
expect(fake.usedTokens).toHaveLength(2)
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
type LogMethod = (...args: unknown[]) => void
|
|
2
|
+
|
|
3
|
+
export type NoopLogger = {
|
|
4
|
+
trace: LogMethod
|
|
5
|
+
debug: LogMethod
|
|
6
|
+
info: LogMethod
|
|
7
|
+
warn: LogMethod
|
|
8
|
+
error: LogMethod
|
|
9
|
+
fatal: LogMethod
|
|
10
|
+
bind: (...args: unknown[]) => NoopLogger
|
|
11
|
+
child: (...args: unknown[]) => NoopLogger
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const noop: LogMethod = () => {}
|
|
15
|
+
|
|
16
|
+
/** A logger that discards everything; `bind` and `child` return itself. */
|
|
17
|
+
export const noopLogger: NoopLogger = {
|
|
18
|
+
trace: noop,
|
|
19
|
+
debug: noop,
|
|
20
|
+
info: noop,
|
|
21
|
+
warn: noop,
|
|
22
|
+
error: noop,
|
|
23
|
+
fatal: noop,
|
|
24
|
+
bind: () => noopLogger,
|
|
25
|
+
child: () => noopLogger
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads one argument from a mock's call history as `T`. Bun types
|
|
30
|
+
* `mock.calls` as `never[]` for loosely typed mocks.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* const input = mockCallArg<CreateUserInput>(createUser) // first call, first argument
|
|
34
|
+
*/
|
|
35
|
+
export function mockCallArg<T>(
|
|
36
|
+
fn: { mock: { calls: unknown[][] } },
|
|
37
|
+
callIndex = 0,
|
|
38
|
+
argIndex = 0
|
|
39
|
+
): T {
|
|
40
|
+
return fn.mock.calls[callIndex]?.[argIndex] as T
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ConnectionAuthContractInput,
|
|
3
|
+
connectionAuthContract
|
|
4
|
+
} from './connection-auth-contract'
|
|
5
|
+
export { mockCallArg, type NoopLogger, noopLogger } from './helpers'
|
|
6
|
+
export { ModuleMocker } from './module-mocker'
|
|
7
|
+
export {
|
|
8
|
+
createPrismaMock,
|
|
9
|
+
type DeepMockedPrisma,
|
|
10
|
+
type PrismaMethodMock
|
|
11
|
+
} from './prisma'
|
|
12
|
+
export { createTestStorage, installWindowShim } from './window'
|