@open-mercato/shared 0.6.6-develop.6551.1.1511bfa890 → 0.6.6
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/AGENTS.md +0 -18
- package/dist/lib/auth/server.js +19 -7
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/crud/factory.js +0 -33
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/location/countries.js +4 -10
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/network.js +1 -20
- package/dist/lib/network.js.map +2 -2
- package/dist/lib/string.js +0 -5
- package/dist/lib/string.js.map +2 -2
- package/dist/lib/url-safety.js +7 -14
- package/dist/lib/url-safety.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/package.json +3 -4
- package/src/lib/__tests__/url-safety.test.ts +0 -28
- package/src/lib/auth/__tests__/server.test.ts +39 -0
- package/src/lib/auth/server.ts +38 -8
- package/src/lib/crud/factory.ts +0 -55
- package/src/lib/location/countries.ts +4 -15
- package/src/lib/network.ts +1 -28
- package/src/lib/string.ts +0 -8
- package/src/lib/url-safety.ts +14 -33
- package/src/modules/payment_gateways/types.ts +0 -2
- package/src/lib/__tests__/string.test.ts +0 -49
- package/src/lib/location/__tests__/countries.test.ts +0 -82
package/src/lib/crud/factory.ts
CHANGED
|
@@ -510,14 +510,6 @@ function json(data: any, init?: ResponseInit) {
|
|
|
510
510
|
})
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
-
// Name of the selected-organization cookie (mirrors the directory module's
|
|
514
|
-
// OrganizationSwitcher, which writes `om_selected_org=...; path=/; samesite=lax`).
|
|
515
|
-
// Kept as a local literal so shared has no import dependency on a domain package.
|
|
516
|
-
const SELECTED_ORG_COOKIE = 'om_selected_org'
|
|
517
|
-
// Set-Cookie value that expires the stale selection so the next request falls
|
|
518
|
-
// back to the caller's home org. Attributes mirror how the switcher sets it.
|
|
519
|
-
const CLEAR_SELECTED_ORG_COOKIE = `${SELECTED_ORG_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`
|
|
520
|
-
|
|
521
513
|
function attachOperationHeader(res: Response, logEntry: any) {
|
|
522
514
|
if (!res || !(res instanceof Response)) return res
|
|
523
515
|
if (!logEntry || typeof logEntry !== 'object') return res
|
|
@@ -1347,42 +1339,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1347
1339
|
return { container, auth: scopedAuth, organizationScope: scope, selectedOrganizationId, organizationIds, request }
|
|
1348
1340
|
}
|
|
1349
1341
|
|
|
1350
|
-
// The caller explicitly selected an organization (selected-org cookie) that no
|
|
1351
|
-
// longer resolves to a real, accessible org — e.g. a stale cookie after a DB
|
|
1352
|
-
// reset, or after the caller lost access to that org. Rather than silently
|
|
1353
|
-
// acting against a fallback org the caller did not select (writes) or showing
|
|
1354
|
-
// a different org's data than the one selected (reads), fail loud on every
|
|
1355
|
-
// org-scoped record operation so the client re-selects. Recovery surfaces
|
|
1356
|
-
// (org switcher, nav, profile) are custom routes, not this factory, so they
|
|
1357
|
-
// keep working. Returns a 422 Response when rejected, otherwise null.
|
|
1358
|
-
function rejectInvalidOrgSelection(ctx: CrudCtx, action: 'list' | 'create' | 'update' | 'delete'): Response | null {
|
|
1359
|
-
if (!ormCfg.orgField) return null
|
|
1360
|
-
if (!(ctx.organizationScope as OrganizationScope | null | undefined)?.selectionRejected) return null
|
|
1361
|
-
logForbidden({
|
|
1362
|
-
resourceKind,
|
|
1363
|
-
action,
|
|
1364
|
-
reason: 'organization_selection_invalid',
|
|
1365
|
-
userId: ctx.auth?.sub ?? null,
|
|
1366
|
-
tenantId: ctx.auth?.tenantId ?? null,
|
|
1367
|
-
organizationIds: ctx.organizationIds,
|
|
1368
|
-
})
|
|
1369
|
-
// Self-heal reads: expire the stale selected-org cookie so the caller's next
|
|
1370
|
-
// request falls back to their home org and the session recovers on its own
|
|
1371
|
-
// (important for single-org users, who have no org switcher to re-select
|
|
1372
|
-
// from). Writes intentionally do NOT clear it — a mutation must go through an
|
|
1373
|
-
// explicit, valid re-selection, never silently target a fallback org.
|
|
1374
|
-
const headers: Record<string, string> = action === 'list'
|
|
1375
|
-
? { 'set-cookie': CLEAR_SELECTED_ORG_COOKIE }
|
|
1376
|
-
: {}
|
|
1377
|
-
return json(
|
|
1378
|
-
{
|
|
1379
|
-
error: 'Your selected organization is no longer available. Please re-select an organization and try again.',
|
|
1380
|
-
code: 'organization_selection_invalid',
|
|
1381
|
-
},
|
|
1382
|
-
{ status: 422, headers },
|
|
1383
|
-
)
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
1342
|
async function GET(request: Request) {
|
|
1387
1343
|
const profiler = createCrudProfiler(resourceKind, 'list')
|
|
1388
1344
|
const requestMeta: Record<string, unknown> = { method: request.method }
|
|
@@ -1410,11 +1366,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1410
1366
|
finishProfile({ reason: 'unauthorized' })
|
|
1411
1367
|
return json({ error: 'Unauthorized' }, { status: 401 })
|
|
1412
1368
|
}
|
|
1413
|
-
const listSelectionRejected = rejectInvalidOrgSelection(ctx, 'list')
|
|
1414
|
-
if (listSelectionRejected) {
|
|
1415
|
-
finishProfile({ reason: 'organization_selection_invalid' })
|
|
1416
|
-
return listSelectionRejected
|
|
1417
|
-
}
|
|
1418
1369
|
if (!opts.list) {
|
|
1419
1370
|
finishProfile({ reason: 'list_not_configured' })
|
|
1420
1371
|
return json({ error: 'Not implemented' }, { status: 501 })
|
|
@@ -2081,8 +2032,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2081
2032
|
if (!opts.create && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
|
|
2082
2033
|
const ctx = await withCtx(request)
|
|
2083
2034
|
if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
|
|
2084
|
-
const createSelectionRejected = rejectInvalidOrgSelection(ctx, 'create')
|
|
2085
|
-
if (createSelectionRejected) return createSelectionRejected
|
|
2086
2035
|
if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
|
|
2087
2036
|
logForbidden({
|
|
2088
2037
|
resourceKind,
|
|
@@ -2353,8 +2302,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2353
2302
|
if (!opts.update && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
|
|
2354
2303
|
const ctx = await withCtx(request)
|
|
2355
2304
|
if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
|
|
2356
|
-
const updateSelectionRejected = rejectInvalidOrgSelection(ctx, 'update')
|
|
2357
|
-
if (updateSelectionRejected) return updateSelectionRejected
|
|
2358
2305
|
if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
|
|
2359
2306
|
logForbidden({
|
|
2360
2307
|
resourceKind,
|
|
@@ -2684,8 +2631,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2684
2631
|
try {
|
|
2685
2632
|
const ctx = await withCtx(request)
|
|
2686
2633
|
if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
|
|
2687
|
-
const deleteSelectionRejected = rejectInvalidOrgSelection(ctx, 'delete')
|
|
2688
|
-
if (deleteSelectionRejected) return deleteSelectionRejected
|
|
2689
2634
|
if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
|
|
2690
2635
|
logForbidden({
|
|
2691
2636
|
resourceKind,
|
|
@@ -28,18 +28,9 @@ const RAW_COUNTRIES: IsoCountry[] = (registry as RegistryEntry[])
|
|
|
28
28
|
name: entry.Description.join(', '),
|
|
29
29
|
}))
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
{ code: 'XK', name: 'Kosovo' },
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
const RAW_COUNTRY_CODES = new Set(RAW_COUNTRIES.map((entry) => entry.code))
|
|
38
|
-
|
|
39
|
-
export const ISO_COUNTRIES: IsoCountry[] = [
|
|
40
|
-
...RAW_COUNTRIES,
|
|
41
|
-
...SUPPLEMENTAL_COUNTRIES.filter((entry) => !RAW_COUNTRY_CODES.has(entry.code)),
|
|
42
|
-
].sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))
|
|
31
|
+
export const ISO_COUNTRIES: IsoCountry[] = [...RAW_COUNTRIES].sort((a, b) =>
|
|
32
|
+
a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })
|
|
33
|
+
)
|
|
43
34
|
|
|
44
35
|
export const COUNTRY_PRIORITY: string[] = ['PL', 'DE', 'ES', 'FR', 'IT', 'US', 'GB', 'CA']
|
|
45
36
|
|
|
@@ -67,9 +58,7 @@ export function resolveCountryName(code: string, options: { locale?: string } =
|
|
|
67
58
|
if (!displayNames) return fallback
|
|
68
59
|
try {
|
|
69
60
|
const label = displayNames.of(normalized as any)
|
|
70
|
-
|
|
71
|
-
if (typeof label !== 'string' || label === normalized) return fallback
|
|
72
|
-
return label
|
|
61
|
+
return typeof label === 'string' ? label : fallback
|
|
73
62
|
} catch {
|
|
74
63
|
return fallback
|
|
75
64
|
}
|
package/src/lib/network.ts
CHANGED
|
@@ -80,39 +80,12 @@ export function isPrivateIPv6(address: string): boolean {
|
|
|
80
80
|
if (isIPv4CompatibleIPv6(segments)) return true
|
|
81
81
|
|
|
82
82
|
const embedded = embeddedIPv4FromIPv6(segments)
|
|
83
|
-
if (embedded
|
|
83
|
+
if (embedded && isPrivateIPv4(embedded)) return true
|
|
84
84
|
|
|
85
85
|
const [a] = segments
|
|
86
86
|
if ((a & 0xfe00) === 0xfc00) return true
|
|
87
87
|
if ((a & 0xffc0) === 0xfe80) return true
|
|
88
88
|
if ((a & 0xff00) === 0xff00) return true
|
|
89
|
-
if (
|
|
90
|
-
a === 0x0100 &&
|
|
91
|
-
segments[1] === 0 &&
|
|
92
|
-
segments[2] === 0 &&
|
|
93
|
-
segments[3] === 0
|
|
94
|
-
) {
|
|
95
|
-
return true
|
|
96
|
-
}
|
|
97
|
-
if (a === 0x2001 && (segments[1] & 0xfe00) === 0) {
|
|
98
|
-
return !isGloballyReachableIetfProtocolAssignment(segments)
|
|
99
|
-
}
|
|
100
|
-
if (a === 0x2001 && segments[1] === 0x0db8) return true
|
|
101
|
-
if ((a & 0xfff0) === 0x3ff0) return true
|
|
102
|
-
// Except for the explicitly handled IPv4 translation ranges above, globally
|
|
103
|
-
// routable unicast IPv6 addresses are allocated from 2000::/3.
|
|
104
|
-
if ((a & 0xe000) !== 0x2000) return true
|
|
105
|
-
return false
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function isGloballyReachableIetfProtocolAssignment(segments: number[]): boolean {
|
|
109
|
-
const [, b, c, d, e, f, g, h] = segments
|
|
110
|
-
const trailingZero = c === 0 && d === 0 && e === 0 && f === 0 && g === 0
|
|
111
|
-
if (b === 0x0001 && trailingZero && h >= 1 && h <= 3) return true
|
|
112
|
-
if (b === 0x0003) return true
|
|
113
|
-
if (b === 0x0004 && c === 0x0112) return true
|
|
114
|
-
if ((b & 0xfff0) === 0x0020) return true
|
|
115
|
-
if ((b & 0xfff0) === 0x0030) return true
|
|
116
89
|
return false
|
|
117
90
|
}
|
|
118
91
|
|
package/src/lib/string.ts
CHANGED
|
@@ -6,11 +6,3 @@ export function trimToUndefined(value: unknown): string | undefined {
|
|
|
6
6
|
return trimmed.length > 0 ? trimmed : undefined
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export function parseCommaSeparatedList(value: string | null | undefined): string[] {
|
|
10
|
-
if (typeof value !== 'string') return []
|
|
11
|
-
return value
|
|
12
|
-
.split(',')
|
|
13
|
-
.map((entry) => entry.trim())
|
|
14
|
-
.filter(Boolean)
|
|
15
|
-
}
|
|
16
|
-
|
package/src/lib/url-safety.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { lookup } from 'node:dns/promises'
|
|
2
2
|
import { isIP } from 'node:net'
|
|
3
|
-
import { Agent,
|
|
3
|
+
import { Agent, type Dispatcher } from 'undici'
|
|
4
4
|
import { isBlockedHostname, isPrivateIpAddress } from './network'
|
|
5
5
|
|
|
6
6
|
export type UrlSafetyReason =
|
|
@@ -102,8 +102,6 @@ export function assertStaticallySafeOutboundUrl(
|
|
|
102
102
|
|
|
103
103
|
export type AssertSafeOutboundUrlOptions = AssertStaticUrlOptions & {
|
|
104
104
|
lookupHost?: HostLookup
|
|
105
|
-
/** Resolve and return DNS answers even when private addresses are explicitly allowed. */
|
|
106
|
-
pinAllowedPrivate?: boolean
|
|
107
105
|
}
|
|
108
106
|
|
|
109
107
|
export async function assertSafeOutboundUrl(
|
|
@@ -120,8 +118,7 @@ export type ResolveSafeOutboundUrlResult = {
|
|
|
120
118
|
hostname: string
|
|
121
119
|
/**
|
|
122
120
|
* The validated DNS records, in lookup order. `null` when the hostname is an IP literal
|
|
123
|
-
* (no DNS lookup performed) or when `allowPrivate` short-circuited validation
|
|
124
|
-
* `pinAllowedPrivate`.
|
|
121
|
+
* (no DNS lookup performed) or when `allowPrivate` short-circuited validation.
|
|
125
122
|
*/
|
|
126
123
|
addresses: ReadonlyArray<ResolvedHostAddress> | null
|
|
127
124
|
}
|
|
@@ -139,16 +136,14 @@ export async function resolveSafeOutboundUrl(
|
|
|
139
136
|
const subject = options.subject ?? 'URL'
|
|
140
137
|
const factory = options.errorFactory ?? defaultErrorFactory
|
|
141
138
|
const { url, hostname } = parseOutboundUrl(rawUrl, options)
|
|
142
|
-
if (options.allowPrivate
|
|
143
|
-
return { url, hostname, addresses: null }
|
|
144
|
-
}
|
|
139
|
+
if (options.allowPrivate) return { url, hostname, addresses: null }
|
|
145
140
|
|
|
146
|
-
if (
|
|
141
|
+
if (isBlockedHostname(hostname)) {
|
|
147
142
|
throw factory('blocked_hostname', `${subject} host "${hostname}" is not allowed`)
|
|
148
143
|
}
|
|
149
144
|
|
|
150
145
|
if (isIP(hostname)) {
|
|
151
|
-
if (
|
|
146
|
+
if (isPrivateIpAddress(hostname)) {
|
|
152
147
|
throw factory(
|
|
153
148
|
'private_ip_literal',
|
|
154
149
|
`${subject} host "${hostname}" resolves to a private or reserved IP range`,
|
|
@@ -184,7 +179,7 @@ export async function resolveSafeOutboundUrl(
|
|
|
184
179
|
}
|
|
185
180
|
|
|
186
181
|
for (const record of addresses) {
|
|
187
|
-
if (
|
|
182
|
+
if (isPrivateIpAddress(record.address)) {
|
|
188
183
|
throw factory(
|
|
189
184
|
'private_ip_resolved',
|
|
190
185
|
`${subject} host "${hostname}" resolves to a private or reserved IP address (${record.address})`,
|
|
@@ -202,23 +197,16 @@ export type SafeOutboundFetchOptions = AssertSafeOutboundUrlOptions & {
|
|
|
202
197
|
* not actually open sockets, so DNS pinning is unnecessary and would just complicate mocking.
|
|
203
198
|
*/
|
|
204
199
|
fetchImpl?: typeof fetch
|
|
205
|
-
/** Test seam for the dispatcher-compatible fetch used by DNS-pinned requests. */
|
|
206
|
-
pinnedFetchImpl?: PinnedFetch
|
|
207
200
|
}
|
|
208
201
|
|
|
209
|
-
export type PinnedFetch = (
|
|
210
|
-
rawUrl: string,
|
|
211
|
-
init: RequestInit & { dispatcher: Dispatcher },
|
|
212
|
-
) => Promise<Response>
|
|
213
|
-
|
|
214
202
|
/**
|
|
215
203
|
* Validates an outbound URL and performs `fetch()` with the connection pinned to a
|
|
216
204
|
* pre-validated IP address, so DNS cannot be re-resolved between validation and connect
|
|
217
205
|
* (DNS rebinding). Always defaults to `redirect: 'manual'` — callers MUST decide what to
|
|
218
206
|
* do with 3xx responses (re-validate the redirect target before following).
|
|
219
207
|
*
|
|
220
|
-
* For IP literal hosts no DNS pinning is performed because there
|
|
221
|
-
*
|
|
208
|
+
* For IP literal hosts and `allowPrivate=true`, no DNS pinning is performed because there
|
|
209
|
+
* is no DNS lookup to defeat.
|
|
222
210
|
*/
|
|
223
211
|
export async function safeOutboundFetch(
|
|
224
212
|
rawUrl: string,
|
|
@@ -249,8 +237,9 @@ export async function safeOutboundFetch(
|
|
|
249
237
|
})
|
|
250
238
|
|
|
251
239
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
240
|
+
return await globalThis.fetch(rawUrl, { ...mergedInit, dispatcher } as RequestInit & {
|
|
241
|
+
dispatcher: Dispatcher
|
|
242
|
+
})
|
|
254
243
|
} finally {
|
|
255
244
|
dispatcher.close().catch(() => {})
|
|
256
245
|
}
|
|
@@ -258,12 +247,8 @@ export async function safeOutboundFetch(
|
|
|
258
247
|
|
|
259
248
|
export type PinnedDnsLookup = (
|
|
260
249
|
host: string,
|
|
261
|
-
opts:
|
|
262
|
-
cb: (
|
|
263
|
-
err: NodeJS.ErrnoException | null,
|
|
264
|
-
address: string | ResolvedHostAddress[],
|
|
265
|
-
family?: number,
|
|
266
|
-
) => void,
|
|
250
|
+
opts: unknown,
|
|
251
|
+
cb: (err: NodeJS.ErrnoException | null, address: string, family: number) => void,
|
|
267
252
|
) => void
|
|
268
253
|
|
|
269
254
|
/**
|
|
@@ -276,7 +261,7 @@ export function createPinnedDnsLookup(
|
|
|
276
261
|
expectedHostname: string,
|
|
277
262
|
pinned: ResolvedHostAddress,
|
|
278
263
|
): PinnedDnsLookup {
|
|
279
|
-
return (host,
|
|
264
|
+
return (host, _opts, cb) => {
|
|
280
265
|
if (host !== expectedHostname) {
|
|
281
266
|
const err: NodeJS.ErrnoException = new Error(
|
|
282
267
|
`Refusing DNS lookup for unexpected host "${host}" (expected "${expectedHostname}")`,
|
|
@@ -285,10 +270,6 @@ export function createPinnedDnsLookup(
|
|
|
285
270
|
cb(err, '', 0)
|
|
286
271
|
return
|
|
287
272
|
}
|
|
288
|
-
if (opts.all) {
|
|
289
|
-
cb(null, [pinned])
|
|
290
|
-
return
|
|
291
|
-
}
|
|
292
273
|
cb(null, pinned.address, pinned.family)
|
|
293
274
|
}
|
|
294
275
|
}
|
|
@@ -117,8 +117,6 @@ export type PaymentGatewayClientSession =
|
|
|
117
117
|
export interface CreateSessionInput {
|
|
118
118
|
orderId?: string
|
|
119
119
|
paymentId: string
|
|
120
|
-
/** Stable operation key for provider-native session creation idempotency. */
|
|
121
|
-
idempotencyKey?: string
|
|
122
120
|
tenantId: string
|
|
123
121
|
organizationId: string
|
|
124
122
|
amount: number
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { parseCommaSeparatedList, trimToUndefined } from '../string'
|
|
2
|
-
|
|
3
|
-
describe('parseCommaSeparatedList', () => {
|
|
4
|
-
it('splits a comma-separated string into entries', () => {
|
|
5
|
-
expect(parseCommaSeparatedList('low,medium,high')).toEqual(['low', 'medium', 'high'])
|
|
6
|
-
})
|
|
7
|
-
|
|
8
|
-
it('trims surrounding whitespace from every entry', () => {
|
|
9
|
-
expect(parseCommaSeparatedList(' admin , editor,viewer ')).toEqual(['admin', 'editor', 'viewer'])
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
it('drops empty entries produced by repeated or trailing separators', () => {
|
|
13
|
-
expect(parseCommaSeparatedList('a,,b,')).toEqual(['a', 'b'])
|
|
14
|
-
expect(parseCommaSeparatedList(',a, ,b')).toEqual(['a', 'b'])
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it('returns an empty array when every entry is blank', () => {
|
|
18
|
-
expect(parseCommaSeparatedList(',,')).toEqual([])
|
|
19
|
-
expect(parseCommaSeparatedList(' , ')).toEqual([])
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('returns an empty array for blank or non-string inputs', () => {
|
|
23
|
-
expect(parseCommaSeparatedList('')).toEqual([])
|
|
24
|
-
expect(parseCommaSeparatedList(' ')).toEqual([])
|
|
25
|
-
expect(parseCommaSeparatedList(null)).toEqual([])
|
|
26
|
-
expect(parseCommaSeparatedList(undefined)).toEqual([])
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('returns a single entry when the input has no separator', () => {
|
|
30
|
-
expect(parseCommaSeparatedList('admin')).toEqual(['admin'])
|
|
31
|
-
expect(parseCommaSeparatedList(' admin ')).toEqual(['admin'])
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('preserves interior whitespace within an entry', () => {
|
|
35
|
-
expect(parseCommaSeparatedList('order created, order shipped')).toEqual(['order created', 'order shipped'])
|
|
36
|
-
})
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
describe('trimToUndefined', () => {
|
|
40
|
-
it('returns the trimmed value when it is a non-blank string', () => {
|
|
41
|
-
expect(trimToUndefined(' value ')).toBe('value')
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('returns undefined for blank or non-string values', () => {
|
|
45
|
-
expect(trimToUndefined(' ')).toBeUndefined()
|
|
46
|
-
expect(trimToUndefined(null)).toBeUndefined()
|
|
47
|
-
expect(trimToUndefined(42)).toBeUndefined()
|
|
48
|
-
})
|
|
49
|
-
})
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
COUNTRY_PRIORITY,
|
|
3
|
-
ISO_COUNTRIES,
|
|
4
|
-
buildCountryOptions,
|
|
5
|
-
resolveCountryName,
|
|
6
|
-
} from '../countries'
|
|
7
|
-
|
|
8
|
-
describe('ISO_COUNTRIES', () => {
|
|
9
|
-
it('includes Kosovo, which the language-subtag registry does not list', () => {
|
|
10
|
-
const kosovo = ISO_COUNTRIES.filter((entry) => entry.code === 'XK')
|
|
11
|
-
expect(kosovo).toEqual([{ code: 'XK', name: 'Kosovo' }])
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
it('does not duplicate any country code', () => {
|
|
15
|
-
const codes = ISO_COUNTRIES.map((entry) => entry.code)
|
|
16
|
-
expect(codes).toHaveLength(new Set(codes).size)
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('keeps registry-derived countries intact', () => {
|
|
20
|
-
expect(ISO_COUNTRIES).toEqual(
|
|
21
|
-
expect.arrayContaining([
|
|
22
|
-
{ code: 'PL', name: 'Poland' },
|
|
23
|
-
{ code: 'DE', name: 'Germany' },
|
|
24
|
-
])
|
|
25
|
-
)
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('stays sorted by name', () => {
|
|
29
|
-
const names = ISO_COUNTRIES.map((entry) => entry.name)
|
|
30
|
-
const sorted = [...names].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
|
|
31
|
-
expect(names).toEqual(sorted)
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
describe('resolveCountryName', () => {
|
|
36
|
-
it('resolves Kosovo for XK', () => {
|
|
37
|
-
expect(resolveCountryName('XK')).toBe('Kosovo')
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('resolves Kosovo for a lowercase xk', () => {
|
|
41
|
-
expect(resolveCountryName('xk')).toBe('Kosovo')
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('still resolves registry-backed countries', () => {
|
|
45
|
-
expect(resolveCountryName('PL')).toBe('Poland')
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('falls back to the raw code for an unknown region', () => {
|
|
49
|
-
expect(resolveCountryName('ZZZ')).toBe('ZZZ')
|
|
50
|
-
})
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
describe('buildCountryOptions', () => {
|
|
54
|
-
it('offers Kosovo as a selectable option', () => {
|
|
55
|
-
const options = buildCountryOptions()
|
|
56
|
-
expect(options).toEqual(expect.arrayContaining([{ code: 'XK', label: 'Kosovo' }]))
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('offers exactly one Kosovo option', () => {
|
|
60
|
-
const options = buildCountryOptions().filter((option) => option.code === 'XK')
|
|
61
|
-
expect(options).toHaveLength(1)
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('lists Kosovo after the prioritized countries', () => {
|
|
65
|
-
const options = buildCountryOptions()
|
|
66
|
-
const kosovoIndex = options.findIndex((option) => option.code === 'XK')
|
|
67
|
-
expect(kosovoIndex).toBeGreaterThanOrEqual(COUNTRY_PRIORITY.length)
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('keeps the prioritized countries at the top', () => {
|
|
71
|
-
const options = buildCountryOptions()
|
|
72
|
-
const leading = options.slice(0, COUNTRY_PRIORITY.length).map((option) => option.code)
|
|
73
|
-
expect(leading.slice().sort()).toEqual(COUNTRY_PRIORITY.slice().sort())
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
it('applies transformLabel to the supplemental country too', () => {
|
|
77
|
-
const options = buildCountryOptions({
|
|
78
|
-
transformLabel: (code, defaultLabel) => `${defaultLabel} (${code})`,
|
|
79
|
-
})
|
|
80
|
-
expect(options).toEqual(expect.arrayContaining([{ code: 'XK', label: 'Kosovo (XK)' }]))
|
|
81
|
-
})
|
|
82
|
-
})
|