@open-mercato/shared 0.8.1-develop.7296.1.2111d779db → 0.9.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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -0
- package/dist/lib/auth/jwt.js +4 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/organizationScope.js +15 -1
- package/dist/lib/auth/organizationScope.js.map +2 -2
- package/dist/lib/availability/catalogOnlyProvider.js +77 -0
- package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
- package/dist/lib/availability/index.js +8 -0
- package/dist/lib/availability/index.js.map +7 -0
- package/dist/lib/availability/registry.js +47 -0
- package/dist/lib/availability/registry.js.map +7 -0
- package/dist/lib/availability/types.js +7 -0
- package/dist/lib/availability/types.js.map +7 -0
- package/dist/lib/catalog-visibility/index.js +11 -0
- package/dist/lib/catalog-visibility/index.js.map +7 -0
- package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
- package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesOne.js +27 -0
- package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesScope.js +9 -0
- package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
- package/dist/lib/catalog-visibility/types.js +1 -0
- package/dist/lib/catalog-visibility/types.js.map +7 -0
- package/dist/lib/catalog-visibility/unionScopes.js +9 -0
- package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
- package/dist/lib/commands/command-bus.js +383 -125
- package/dist/lib/commands/command-bus.js.map +3 -3
- package/dist/lib/commands/flush.js +16 -0
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/index.js +8 -0
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/commands/redo.js +2 -7
- package/dist/lib/commands/redo.js.map +2 -2
- package/dist/lib/commands/transaction-lifetime.js +67 -0
- package/dist/lib/commands/transaction-lifetime.js.map +7 -0
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/commands/undo.js +54 -2
- package/dist/lib/commands/undo.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +64 -9
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +42 -18
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +12 -3
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/accentInsensitiveSearch.js +21 -0
- package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
- package/dist/lib/encryption/kms.js +1 -5
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +3 -1
- package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
- package/dist/lib/encryption/migration-backfill.js +69 -0
- package/dist/lib/encryption/migration-backfill.js.map +7 -0
- package/dist/lib/encryption/policyMemo.js +28 -0
- package/dist/lib/encryption/policyMemo.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +30 -3
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
- package/dist/lib/modules/api-route-identity.js +102 -0
- package/dist/lib/modules/api-route-identity.js.map +7 -0
- package/dist/lib/telemetry/runtime.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +20 -0
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +3 -1
- package/dist/modules/registry.js.map +2 -2
- package/package.json +12 -5
- package/src/lib/auth/__tests__/jwt.test.ts +12 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
- package/src/lib/auth/jwt.ts +9 -0
- package/src/lib/auth/organizationScope.ts +39 -0
- package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
- package/src/lib/availability/__tests__/registry.test.ts +130 -0
- package/src/lib/availability/catalogOnlyProvider.ts +152 -0
- package/src/lib/availability/index.ts +18 -0
- package/src/lib/availability/registry.ts +131 -0
- package/src/lib/availability/types.ts +86 -0
- package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
- package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
- package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
- package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
- package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
- package/src/lib/catalog-visibility/index.ts +5 -0
- package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
- package/src/lib/catalog-visibility/matchesOne.ts +28 -0
- package/src/lib/catalog-visibility/matchesScope.ts +8 -0
- package/src/lib/catalog-visibility/types.ts +31 -0
- package/src/lib/catalog-visibility/unionScopes.ts +16 -0
- package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
- package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
- package/src/lib/commands/__tests__/redo.test.ts +4 -0
- package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
- package/src/lib/commands/__tests__/undo.test.ts +90 -1
- package/src/lib/commands/command-bus.ts +463 -148
- package/src/lib/commands/flush.ts +16 -0
- package/src/lib/commands/index.ts +7 -0
- package/src/lib/commands/redo.ts +4 -8
- package/src/lib/commands/transaction-lifetime.ts +88 -0
- package/src/lib/commands/types.ts +91 -0
- package/src/lib/commands/undo.ts +79 -1
- package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
- package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
- package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
- package/src/lib/crud/enricher-runner.ts +83 -9
- package/src/lib/crud/factory.ts +46 -19
- package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
- package/src/lib/data/engine.ts +12 -0
- package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
- package/src/lib/db/accentInsensitiveSearch.ts +70 -0
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
- package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
- package/src/lib/encryption/__tests__/kms.test.ts +45 -3
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
- package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
- package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
- package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
- package/src/lib/encryption/kms.ts +1 -6
- package/src/lib/encryption/likeFilterWarning.ts +10 -2
- package/src/lib/encryption/migration-backfill.ts +90 -0
- package/src/lib/encryption/policyMemo.ts +51 -0
- package/src/lib/encryption/subscriber.ts +41 -4
- package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
- package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
- package/src/lib/modules/api-route-identity.ts +116 -0
- package/src/lib/telemetry/runtime.ts +8 -0
- package/src/modules/__tests__/registry.test.ts +92 -0
- package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
- package/src/modules/events/factory.ts +31 -0
- package/src/modules/events/types.ts +13 -0
- package/src/modules/notifications/types.ts +1 -0
- package/src/modules/registry.ts +4 -1
|
@@ -15,6 +15,10 @@ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
|
15
15
|
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
16
16
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
17
17
|
import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
|
|
18
|
+
import {
|
|
19
|
+
findApiRouteManifestMatch,
|
|
20
|
+
type ApiRouteManifestEntry,
|
|
21
|
+
} from '@open-mercato/shared/modules/registry'
|
|
18
22
|
import {
|
|
19
23
|
clearOptimisticLockReadersForTests,
|
|
20
24
|
getAllOptimisticLockReaders,
|
|
@@ -185,17 +189,28 @@ const accessLogService = {
|
|
|
185
189
|
log: jest.fn(async () => {}),
|
|
186
190
|
}
|
|
187
191
|
|
|
192
|
+
const crudCacheStore = new Map<string, unknown>()
|
|
193
|
+
const crudCache = {
|
|
194
|
+
get: jest.fn(async (key: string) => crudCacheStore.get(key) ?? null),
|
|
195
|
+
set: jest.fn(async (key: string, value: unknown) => { crudCacheStore.set(key, value) }),
|
|
196
|
+
delete: jest.fn(async (key: string) => { crudCacheStore.delete(key) }),
|
|
197
|
+
deleteByTags: jest.fn(async () => 0),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const resolveContainerValue = jest.fn((name: string) => ({
|
|
201
|
+
em,
|
|
202
|
+
queryEngine,
|
|
203
|
+
cache: crudCache,
|
|
204
|
+
eventBus: mockEventBus,
|
|
205
|
+
dataEngine: mockDataEngine,
|
|
206
|
+
accessLogService,
|
|
207
|
+
commandBus,
|
|
208
|
+
crudMutationGuardService,
|
|
209
|
+
} as any)[name])
|
|
210
|
+
|
|
188
211
|
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
189
212
|
createRequestContainer: async () => ({
|
|
190
|
-
resolve:
|
|
191
|
-
em,
|
|
192
|
-
queryEngine,
|
|
193
|
-
eventBus: mockEventBus,
|
|
194
|
-
dataEngine: mockDataEngine,
|
|
195
|
-
accessLogService,
|
|
196
|
-
commandBus,
|
|
197
|
-
crudMutationGuardService,
|
|
198
|
-
} as any)[name],
|
|
213
|
+
resolve: resolveContainerValue,
|
|
199
214
|
})
|
|
200
215
|
}))
|
|
201
216
|
|
|
@@ -235,6 +250,7 @@ describe('CRUD Factory', () => {
|
|
|
235
250
|
idSeq = 1
|
|
236
251
|
jest.clearAllMocks()
|
|
237
252
|
accessLogService.log.mockClear()
|
|
253
|
+
crudCacheStore.clear()
|
|
238
254
|
mockDataEngine.__pendingSideEffects = []
|
|
239
255
|
mockDataEngine.__defaultIndexer = null
|
|
240
256
|
mockDataEngine.__indexedDefaultEntityClass = false
|
|
@@ -309,6 +325,44 @@ describe('CRUD Factory', () => {
|
|
|
309
325
|
}))
|
|
310
326
|
})
|
|
311
327
|
|
|
328
|
+
it('GET returns an empty page before hooks, cache, query engine, EM, RBAC, or data engine for explicit empty scope', async () => {
|
|
329
|
+
const beforeList = jest.fn()
|
|
330
|
+
const emptyScopeRoute = makeCrudRoute({
|
|
331
|
+
metadata: { GET: { requireAuth: true } },
|
|
332
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId' },
|
|
333
|
+
indexer: { entityType: 'example.todo' },
|
|
334
|
+
list: {
|
|
335
|
+
schema: querySchema,
|
|
336
|
+
entityId: 'example.todo',
|
|
337
|
+
fields: ['id', 'title'],
|
|
338
|
+
buildFilters: () => ({} as any),
|
|
339
|
+
},
|
|
340
|
+
hooks: { beforeList },
|
|
341
|
+
})
|
|
342
|
+
mockOrganizationScopeOverride = {
|
|
343
|
+
tenantId: defaultTenantId,
|
|
344
|
+
selectedId: defaultOrganizationId,
|
|
345
|
+
filterIds: [],
|
|
346
|
+
allowedIds: [defaultOrganizationId],
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const response = await emptyScopeRoute.GET(new Request('http://x/api/example/todos?page=4&pageSize=15'))
|
|
350
|
+
expect(response.status).toBe(200)
|
|
351
|
+
await expect(response.json()).resolves.toEqual({
|
|
352
|
+
items: [],
|
|
353
|
+
total: 0,
|
|
354
|
+
page: 4,
|
|
355
|
+
pageSize: 15,
|
|
356
|
+
totalPages: 0,
|
|
357
|
+
})
|
|
358
|
+
expect(beforeList).not.toHaveBeenCalled()
|
|
359
|
+
expect(resolveContainerValue).not.toHaveBeenCalled()
|
|
360
|
+
expect(crudCache.get).not.toHaveBeenCalled()
|
|
361
|
+
expect(queryEngine.query).not.toHaveBeenCalled()
|
|
362
|
+
expect(mockDataEngine.createOrmEntity).not.toHaveBeenCalled()
|
|
363
|
+
expect(accessLogService.log).not.toHaveBeenCalled()
|
|
364
|
+
})
|
|
365
|
+
|
|
312
366
|
it('GET spreads totalIsCapped only when the engine reports a capped count', async () => {
|
|
313
367
|
queryEngine.query.mockResolvedValueOnce({
|
|
314
368
|
items: [{ id: 'id-1', title: 'A', is_done: false }],
|
|
@@ -1213,7 +1267,7 @@ describe('CRUD Factory', () => {
|
|
|
1213
1267
|
expect(db[created.id].deletedAt).toBeInstanceOf(Date)
|
|
1214
1268
|
})
|
|
1215
1269
|
|
|
1216
|
-
it('
|
|
1270
|
+
it('denies mutations instead of falling back from empty filter ids', async () => {
|
|
1217
1271
|
const created = em.create(Todo, { title: 'Scoped', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
|
|
1218
1272
|
created.id = '123e4567-e89b-12d3-a456-426614174052'
|
|
1219
1273
|
await em.persist(created).flush()
|
|
@@ -1230,27 +1284,41 @@ describe('CRUD Factory', () => {
|
|
|
1230
1284
|
headers: { 'content-type': 'application/json' },
|
|
1231
1285
|
}))
|
|
1232
1286
|
|
|
1233
|
-
expect(updateResponse.status).toBe(
|
|
1234
|
-
expect(mockDataEngine.updateOrmEntity).
|
|
1235
|
-
where: {
|
|
1236
|
-
id: created.id,
|
|
1237
|
-
organizationId: defaultOrganizationId,
|
|
1238
|
-
tenantId: defaultTenantId,
|
|
1239
|
-
deletedAt: null,
|
|
1240
|
-
},
|
|
1241
|
-
}))
|
|
1287
|
+
expect(updateResponse.status).toBe(403)
|
|
1288
|
+
expect(mockDataEngine.updateOrmEntity).not.toHaveBeenCalled()
|
|
1242
1289
|
|
|
1243
1290
|
const deleteResponse = await route.DELETE(new Request(`http://x/api/example/todos?id=${created.id}`, { method: 'DELETE' }))
|
|
1244
1291
|
|
|
1245
|
-
expect(deleteResponse.status).toBe(
|
|
1246
|
-
expect(mockDataEngine.deleteOrmEntity).
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1292
|
+
expect(deleteResponse.status).toBe(403)
|
|
1293
|
+
expect(mockDataEngine.deleteOrmEntity).not.toHaveBeenCalled()
|
|
1294
|
+
})
|
|
1295
|
+
|
|
1296
|
+
it('does not call data services when the resolved organization scope is explicitly empty', async () => {
|
|
1297
|
+
mockOrganizationScopeOverride = {
|
|
1298
|
+
selectedId: null,
|
|
1299
|
+
filterIds: [],
|
|
1300
|
+
allowedIds: [],
|
|
1301
|
+
tenantId: defaultTenantId,
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const listResponse = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10'))
|
|
1305
|
+
expect(listResponse.status).toBe(200)
|
|
1306
|
+
await expect(listResponse.json()).resolves.toEqual({
|
|
1307
|
+
items: [],
|
|
1308
|
+
total: 0,
|
|
1309
|
+
page: 1,
|
|
1310
|
+
pageSize: 10,
|
|
1311
|
+
totalPages: 0,
|
|
1312
|
+
})
|
|
1313
|
+
expect(queryEngine.query).not.toHaveBeenCalled()
|
|
1314
|
+
|
|
1315
|
+
const createResponse = await route.POST(new Request('http://x/api/example/todos', {
|
|
1316
|
+
method: 'POST',
|
|
1317
|
+
body: JSON.stringify({ title: 'Blocked' }),
|
|
1318
|
+
headers: { 'content-type': 'application/json' },
|
|
1253
1319
|
}))
|
|
1320
|
+
expect(createResponse.status).toBe(403)
|
|
1321
|
+
expect(mockDataEngine.createOrmEntity).not.toHaveBeenCalled()
|
|
1254
1322
|
})
|
|
1255
1323
|
|
|
1256
1324
|
it('PUT mutation guard uses route resource identity instead of spoofed lock headers', async () => {
|
|
@@ -1965,6 +2033,135 @@ describe('CRUD Factory', () => {
|
|
|
1965
2033
|
expect(body._interceptor).toEqual({ ok: true, count: 1 })
|
|
1966
2034
|
})
|
|
1967
2035
|
|
|
2036
|
+
it('preserves exact and prefix interceptor identity through cloned and reconstructed aliases', async () => {
|
|
2037
|
+
const beforeCalls: string[] = []
|
|
2038
|
+
const afterCalls: string[] = []
|
|
2039
|
+
registerApiInterceptors([
|
|
2040
|
+
{
|
|
2041
|
+
moduleId: 'example',
|
|
2042
|
+
interceptors: [
|
|
2043
|
+
{
|
|
2044
|
+
id: 'example.canonical-prefix-policy',
|
|
2045
|
+
targetRoute: 'example/*',
|
|
2046
|
+
methods: ['GET', 'POST'],
|
|
2047
|
+
priority: 100,
|
|
2048
|
+
async before(request) {
|
|
2049
|
+
beforeCalls.push(`prefix:${request.url}`)
|
|
2050
|
+
return { ok: true }
|
|
2051
|
+
},
|
|
2052
|
+
async after(request) {
|
|
2053
|
+
afterCalls.push(`prefix:${request.url}`)
|
|
2054
|
+
return { merge: { canonicalPrefixPolicyApplied: true } }
|
|
2055
|
+
},
|
|
2056
|
+
},
|
|
2057
|
+
{
|
|
2058
|
+
id: 'example.canonical-route-policy',
|
|
2059
|
+
targetRoute: 'example/todos',
|
|
2060
|
+
methods: ['GET', 'POST'],
|
|
2061
|
+
async before(request) {
|
|
2062
|
+
beforeCalls.push(`exact:${request.url}`)
|
|
2063
|
+
if (request.method === 'POST') {
|
|
2064
|
+
return { ok: false, statusCode: 451, message: 'Denied by canonical policy' }
|
|
2065
|
+
}
|
|
2066
|
+
return { ok: true }
|
|
2067
|
+
},
|
|
2068
|
+
async after(request) {
|
|
2069
|
+
afterCalls.push(`exact:${request.url}`)
|
|
2070
|
+
return { merge: { canonicalPolicyApplied: true } }
|
|
2071
|
+
},
|
|
2072
|
+
},
|
|
2073
|
+
],
|
|
2074
|
+
},
|
|
2075
|
+
])
|
|
2076
|
+
|
|
2077
|
+
const manifest: ApiRouteManifestEntry[] = [{
|
|
2078
|
+
moduleId: 'example',
|
|
2079
|
+
kind: 'route-file',
|
|
2080
|
+
path: '/example/todos',
|
|
2081
|
+
methods: ['GET', 'POST'],
|
|
2082
|
+
load: async () => ({}),
|
|
2083
|
+
}]
|
|
2084
|
+
const aliases = [
|
|
2085
|
+
{ urlPath: '/api/example/todos', dispatcherPath: '/example/todos', transform: (request: Request) => request },
|
|
2086
|
+
{ urlPath: '/api/EXAMPLE/TODOS', dispatcherPath: '/EXAMPLE/TODOS', transform: (request: Request) => request.clone() },
|
|
2087
|
+
{ urlPath: '/api/%65xample/%74odos', dispatcherPath: '/example/todos', transform: (request: Request) => new Request(request.url, request) },
|
|
2088
|
+
]
|
|
2089
|
+
|
|
2090
|
+
for (const alias of aliases) {
|
|
2091
|
+
const postRequest = new Request(`http://x${alias.urlPath}?source=alias`, {
|
|
2092
|
+
method: 'POST',
|
|
2093
|
+
body: JSON.stringify({ title: 'Must not be created' }),
|
|
2094
|
+
headers: { 'content-type': 'application/json' },
|
|
2095
|
+
})
|
|
2096
|
+
expect(findApiRouteManifestMatch(manifest, 'POST', alias.dispatcherPath, postRequest)).toBeDefined()
|
|
2097
|
+
const denied = await route.POST(alias.transform(postRequest))
|
|
2098
|
+
expect(denied.status).toBe(451)
|
|
2099
|
+
await expect(denied.json()).resolves.toMatchObject({ error: 'Denied by canonical policy' })
|
|
2100
|
+
|
|
2101
|
+
const getRequest = new Request(`http://x${alias.urlPath}?page=1&pageSize=10`)
|
|
2102
|
+
expect(findApiRouteManifestMatch(manifest, 'GET', alias.dispatcherPath, getRequest)).toBeDefined()
|
|
2103
|
+
const allowed = await route.GET(alias.transform(getRequest))
|
|
2104
|
+
expect(allowed.status).toBe(200)
|
|
2105
|
+
await expect(allowed.json()).resolves.toMatchObject({
|
|
2106
|
+
canonicalPolicyApplied: true,
|
|
2107
|
+
canonicalPrefixPolicyApplied: true,
|
|
2108
|
+
})
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
expect(beforeCalls).toHaveLength(12)
|
|
2112
|
+
expect(afterCalls).toHaveLength(6)
|
|
2113
|
+
expect(mockDataEngine.createOrmEntity).not.toHaveBeenCalled()
|
|
2114
|
+
})
|
|
2115
|
+
|
|
2116
|
+
it('fails a malformed unbound request before interceptor selection or mutation', async () => {
|
|
2117
|
+
const before = jest.fn(async () => ({ ok: true }))
|
|
2118
|
+
registerApiInterceptors([{
|
|
2119
|
+
moduleId: 'example',
|
|
2120
|
+
interceptors: [{
|
|
2121
|
+
id: 'example.malformed-path-probe',
|
|
2122
|
+
targetRoute: 'example/*',
|
|
2123
|
+
methods: ['POST'],
|
|
2124
|
+
before,
|
|
2125
|
+
}],
|
|
2126
|
+
}])
|
|
2127
|
+
|
|
2128
|
+
const response = await route.POST(new Request('http://x/api/example/%ZZ', {
|
|
2129
|
+
method: 'POST',
|
|
2130
|
+
body: JSON.stringify({ title: 'Must not be created' }),
|
|
2131
|
+
headers: { 'content-type': 'application/json' },
|
|
2132
|
+
}))
|
|
2133
|
+
|
|
2134
|
+
expect(response.status).toBe(400)
|
|
2135
|
+
expect(before).not.toHaveBeenCalled()
|
|
2136
|
+
expect(mockDataEngine.createOrmEntity).not.toHaveBeenCalled()
|
|
2137
|
+
})
|
|
2138
|
+
|
|
2139
|
+
it('fails closed for an attacker-supplied route identity header', async () => {
|
|
2140
|
+
const before = jest.fn(async () => ({ ok: true }))
|
|
2141
|
+
registerApiInterceptors([{
|
|
2142
|
+
moduleId: 'example',
|
|
2143
|
+
interceptors: [{
|
|
2144
|
+
id: 'example.forged-identity-probe',
|
|
2145
|
+
targetRoute: 'example/*',
|
|
2146
|
+
methods: ['POST'],
|
|
2147
|
+
before,
|
|
2148
|
+
}],
|
|
2149
|
+
}])
|
|
2150
|
+
|
|
2151
|
+
const response = await route.POST(new Request('http://x/api/example/todos', {
|
|
2152
|
+
method: 'POST',
|
|
2153
|
+
body: JSON.stringify({ title: 'Must not be created' }),
|
|
2154
|
+
headers: {
|
|
2155
|
+
'content-type': 'application/json',
|
|
2156
|
+
'x-open-mercato-route-identity': 'attacker-controlled',
|
|
2157
|
+
},
|
|
2158
|
+
}))
|
|
2159
|
+
|
|
2160
|
+
expect(response.status).toBe(400)
|
|
2161
|
+
expect(before).not.toHaveBeenCalled()
|
|
2162
|
+
expect(mockDataEngine.createOrmEntity).not.toHaveBeenCalled()
|
|
2163
|
+
})
|
|
2164
|
+
|
|
1968
2165
|
// The command DELETE path used to hand before-interceptors the body only, so a
|
|
1969
2166
|
// guard reading `?id=` saw nothing to object to and the delete went through
|
|
1970
2167
|
// with a 200 (issue #4842).
|
|
@@ -1,6 +1,271 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createLogger } from '../../logger'
|
|
2
|
+
import {
|
|
3
|
+
registerTelemetryRuntime,
|
|
4
|
+
resetTelemetryRuntime,
|
|
5
|
+
type TelemetryRuntime,
|
|
6
|
+
} from '../../telemetry/runtime'
|
|
2
7
|
import { registerResponseEnrichers } from '../enricher-registry'
|
|
3
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
applyResponseEnricherToRecord,
|
|
10
|
+
applyResponseEnrichers,
|
|
11
|
+
} from '../enricher-runner'
|
|
12
|
+
import type {
|
|
13
|
+
EnricherContext,
|
|
14
|
+
EnricherRegistryEntry,
|
|
15
|
+
ResponseEnricher,
|
|
16
|
+
} from '../response-enricher'
|
|
17
|
+
|
|
18
|
+
jest.mock('../../logger', () => {
|
|
19
|
+
const mocked = {
|
|
20
|
+
debug: jest.fn(),
|
|
21
|
+
info: jest.fn(),
|
|
22
|
+
warn: jest.fn(),
|
|
23
|
+
error: jest.fn(),
|
|
24
|
+
child: jest.fn(),
|
|
25
|
+
}
|
|
26
|
+
mocked.child.mockImplementation(() => mocked)
|
|
27
|
+
return { createLogger: jest.fn(() => mocked) }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const logger = createLogger('shared').child({ component: 'umes' })
|
|
31
|
+
const loggerDebug = logger.debug as jest.Mock
|
|
32
|
+
const loggerWarn = logger.warn as jest.Mock
|
|
33
|
+
const loggerError = logger.error as jest.Mock
|
|
34
|
+
|
|
35
|
+
const context: EnricherContext = {
|
|
36
|
+
organizationId: 'org-1',
|
|
37
|
+
tenantId: 'tenant-1',
|
|
38
|
+
userId: 'user-1',
|
|
39
|
+
em: {},
|
|
40
|
+
container: {},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeEntry(id: string): EnricherRegistryEntry {
|
|
44
|
+
const enricher: ResponseEnricher<Record<string, unknown>, Record<string, unknown>> = {
|
|
45
|
+
id,
|
|
46
|
+
targetEntity: 'customers.person',
|
|
47
|
+
enrichOne: async (record) => record,
|
|
48
|
+
enrichMany: async (records) => records,
|
|
49
|
+
}
|
|
50
|
+
return { moduleId: 'test', enricher }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeCriticalEnrichingEntry(id: string): EnricherRegistryEntry {
|
|
54
|
+
const entry = makeEntry(id)
|
|
55
|
+
entry.enricher.critical = true
|
|
56
|
+
entry.enricher.enrichMany = async (records) =>
|
|
57
|
+
records.map((record) => ({ ...record, enriched: true }))
|
|
58
|
+
entry.enricher.enrichOne = async (record) => ({ ...record, enriched: true })
|
|
59
|
+
return entry
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mockNow(values: number[]): jest.SpyInstance<number, []> {
|
|
63
|
+
let index = 0
|
|
64
|
+
return jest.spyOn(Date, 'now').mockImplementation(() => {
|
|
65
|
+
const value = values[index]
|
|
66
|
+
if (value === undefined) {
|
|
67
|
+
throw new Error('[internal] Missing mocked Date.now() value')
|
|
68
|
+
}
|
|
69
|
+
index += 1
|
|
70
|
+
return value
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeRuntime(recordHistogram: jest.Mock): TelemetryRuntime {
|
|
75
|
+
return {
|
|
76
|
+
canUseGlobalTracePropagation: () => false,
|
|
77
|
+
captureTraceContext: () => ({}),
|
|
78
|
+
continueTrace: (_carrier, _name, fn) => fn(),
|
|
79
|
+
recordHistogram,
|
|
80
|
+
recordHttpDuration: () => {},
|
|
81
|
+
reportError: () => {},
|
|
82
|
+
shutdown: async () => {},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
jest.useFakeTimers({ doNotFake: ['Date'] })
|
|
88
|
+
loggerDebug.mockClear()
|
|
89
|
+
loggerWarn.mockClear()
|
|
90
|
+
loggerError.mockClear()
|
|
91
|
+
resetTelemetryRuntime()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
jest.restoreAllMocks()
|
|
96
|
+
jest.useRealTimers()
|
|
97
|
+
resetTelemetryRuntime()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('enricher performance reporting', () => {
|
|
101
|
+
it('throttles terminal slow logs per enricher and reports suppressed observations', async () => {
|
|
102
|
+
const entry = makeEntry('test.throttle')
|
|
103
|
+
mockNow([0, 600, 1_000, 1_700, 2_000, 2_900, 31_000, 31_800])
|
|
104
|
+
|
|
105
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
106
|
+
await applyResponseEnrichers([{ id: 'person-1' }], 'customers.person', context, [entry])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
expect(loggerError).toHaveBeenCalledTimes(2)
|
|
110
|
+
expect(loggerError).toHaveBeenNthCalledWith(1, 'Enricher exceeded slow threshold', {
|
|
111
|
+
enricherId: 'test.throttle',
|
|
112
|
+
elapsedMs: 600,
|
|
113
|
+
thresholdMs: 500,
|
|
114
|
+
suppressedCount: 0,
|
|
115
|
+
maxElapsedMs: 600,
|
|
116
|
+
})
|
|
117
|
+
expect(loggerError).toHaveBeenNthCalledWith(2, 'Enricher exceeded slow threshold', {
|
|
118
|
+
enricherId: 'test.throttle',
|
|
119
|
+
elapsedMs: 800,
|
|
120
|
+
thresholdMs: 500,
|
|
121
|
+
suppressedCount: 2,
|
|
122
|
+
maxElapsedMs: 900,
|
|
123
|
+
})
|
|
124
|
+
expect(loggerWarn).not.toHaveBeenCalled()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('routes the diagnostic threshold to debug and the terminal threshold to error', async () => {
|
|
128
|
+
mockNow([0, 100, 1_000, 1_150, 2_000, 2_600])
|
|
129
|
+
|
|
130
|
+
await applyResponseEnrichers(
|
|
131
|
+
[{ id: 'person-1' }],
|
|
132
|
+
'customers.person',
|
|
133
|
+
context,
|
|
134
|
+
[makeEntry('test.fast')],
|
|
135
|
+
)
|
|
136
|
+
await applyResponseEnrichers(
|
|
137
|
+
[{ id: 'person-1' }],
|
|
138
|
+
'customers.person',
|
|
139
|
+
context,
|
|
140
|
+
[makeEntry('test.diagnostic')],
|
|
141
|
+
)
|
|
142
|
+
await applyResponseEnrichers(
|
|
143
|
+
[{ id: 'person-1' }],
|
|
144
|
+
'customers.person',
|
|
145
|
+
context,
|
|
146
|
+
[makeEntry('test.terminal')],
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
expect(loggerDebug).toHaveBeenCalledTimes(1)
|
|
150
|
+
expect(loggerDebug).toHaveBeenCalledWith('Enricher exceeded slow threshold', {
|
|
151
|
+
enricherId: 'test.diagnostic',
|
|
152
|
+
elapsedMs: 150,
|
|
153
|
+
thresholdMs: 100,
|
|
154
|
+
})
|
|
155
|
+
expect(loggerError).toHaveBeenCalledTimes(1)
|
|
156
|
+
expect(loggerError).toHaveBeenCalledWith(
|
|
157
|
+
'Enricher exceeded slow threshold',
|
|
158
|
+
expect.objectContaining({
|
|
159
|
+
enricherId: 'test.terminal',
|
|
160
|
+
elapsedMs: 600,
|
|
161
|
+
thresholdMs: 500,
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
164
|
+
expect(loggerWarn).not.toHaveBeenCalled()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('emits list and record duration histograms only through an active runtime', async () => {
|
|
168
|
+
const recordHistogram = jest.fn()
|
|
169
|
+
registerTelemetryRuntime(makeRuntime(recordHistogram))
|
|
170
|
+
mockNow([0, 250, 1_000, 1_050])
|
|
171
|
+
|
|
172
|
+
await applyResponseEnrichers(
|
|
173
|
+
[{ id: 'person-1' }],
|
|
174
|
+
'customers.person',
|
|
175
|
+
context,
|
|
176
|
+
[makeEntry('test.metric-list')],
|
|
177
|
+
)
|
|
178
|
+
await applyResponseEnricherToRecord(
|
|
179
|
+
{ id: 'person-1' },
|
|
180
|
+
'customers.person',
|
|
181
|
+
context,
|
|
182
|
+
[makeEntry('test.metric-record')],
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
expect(recordHistogram).toHaveBeenNthCalledWith(
|
|
186
|
+
1,
|
|
187
|
+
'om.enricher.duration',
|
|
188
|
+
0.25,
|
|
189
|
+
{ 'enricher.id': 'test.metric-list' },
|
|
190
|
+
's',
|
|
191
|
+
)
|
|
192
|
+
expect(recordHistogram).toHaveBeenNthCalledWith(
|
|
193
|
+
2,
|
|
194
|
+
'om.enricher.duration',
|
|
195
|
+
0.05,
|
|
196
|
+
{ 'enricher.id': 'test.metric-record' },
|
|
197
|
+
's',
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('preserves successful critical enrichment and throttles the warning per enricher when histogram recording fails', async () => {
|
|
202
|
+
const entry = makeCriticalEnrichingEntry('test.metric-failure')
|
|
203
|
+
const otherEntry = makeCriticalEnrichingEntry('test.metric-failure-other')
|
|
204
|
+
const telemetryError = new Error('[internal] Telemetry provider failed')
|
|
205
|
+
const recordHistogram = jest.fn(() => {
|
|
206
|
+
throw telemetryError
|
|
207
|
+
})
|
|
208
|
+
registerTelemetryRuntime(makeRuntime(recordHistogram))
|
|
209
|
+
mockNow([0, 25, 1_000, 1_010, 2_000, 2_020, 31_000, 31_030])
|
|
210
|
+
|
|
211
|
+
const listResult = await applyResponseEnrichers(
|
|
212
|
+
[{ id: 'person-1' }],
|
|
213
|
+
'customers.person',
|
|
214
|
+
context,
|
|
215
|
+
[entry],
|
|
216
|
+
)
|
|
217
|
+
const recordResult = await applyResponseEnricherToRecord(
|
|
218
|
+
{ id: 'person-1' },
|
|
219
|
+
'customers.person',
|
|
220
|
+
context,
|
|
221
|
+
[entry],
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
expect(listResult).toEqual({
|
|
225
|
+
items: [{ id: 'person-1', enriched: true }],
|
|
226
|
+
_meta: { enrichedBy: ['test.metric-failure'] },
|
|
227
|
+
})
|
|
228
|
+
expect(recordResult).toEqual({
|
|
229
|
+
record: { id: 'person-1', enriched: true },
|
|
230
|
+
_meta: { enrichedBy: ['test.metric-failure'] },
|
|
231
|
+
})
|
|
232
|
+
expect(recordHistogram).toHaveBeenCalledTimes(2)
|
|
233
|
+
expect(loggerWarn).toHaveBeenCalledTimes(1)
|
|
234
|
+
expect(loggerWarn).toHaveBeenCalledWith('Enricher duration metric recording failed', {
|
|
235
|
+
enricherId: 'test.metric-failure',
|
|
236
|
+
metric: 'om.enricher.duration',
|
|
237
|
+
err: telemetryError,
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const otherRecordResult = await applyResponseEnricherToRecord(
|
|
241
|
+
{ id: 'person-1' },
|
|
242
|
+
'customers.person',
|
|
243
|
+
context,
|
|
244
|
+
[otherEntry],
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
expect(otherRecordResult).toEqual({
|
|
248
|
+
record: { id: 'person-1', enriched: true },
|
|
249
|
+
_meta: { enrichedBy: ['test.metric-failure-other'] },
|
|
250
|
+
})
|
|
251
|
+
expect(loggerWarn).toHaveBeenCalledTimes(2)
|
|
252
|
+
expect(loggerWarn).toHaveBeenNthCalledWith(2, 'Enricher duration metric recording failed', {
|
|
253
|
+
enricherId: 'test.metric-failure-other',
|
|
254
|
+
metric: 'om.enricher.duration',
|
|
255
|
+
err: telemetryError,
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
await applyResponseEnrichers([{ id: 'person-1' }], 'customers.person', context, [entry])
|
|
259
|
+
|
|
260
|
+
expect(recordHistogram).toHaveBeenCalledTimes(4)
|
|
261
|
+
expect(loggerWarn).toHaveBeenCalledTimes(3)
|
|
262
|
+
expect(loggerWarn).toHaveBeenNthCalledWith(3, 'Enricher duration metric recording failed', {
|
|
263
|
+
enricherId: 'test.metric-failure',
|
|
264
|
+
metric: 'om.enricher.duration',
|
|
265
|
+
err: telemetryError,
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
})
|
|
4
269
|
|
|
5
270
|
describe('enricher runner', () => {
|
|
6
271
|
it('partitions wildcard read-through cache entries by concrete entity', async () => {
|
|
@@ -29,7 +294,7 @@ describe('enricher runner', () => {
|
|
|
29
294
|
}
|
|
30
295
|
registerResponseEnrichers([{ moduleId: 'test', enrichers: [enricher] }])
|
|
31
296
|
|
|
32
|
-
const
|
|
297
|
+
const cacheContext: EnricherContext = {
|
|
33
298
|
organizationId: 'org-1',
|
|
34
299
|
tenantId: 'tenant-1',
|
|
35
300
|
userId: 'user-1',
|
|
@@ -40,12 +305,12 @@ describe('enricher runner', () => {
|
|
|
40
305
|
const personResult = await applyResponseEnricherToRecord(
|
|
41
306
|
{ id: 'shared-id' },
|
|
42
307
|
'customers.person',
|
|
43
|
-
|
|
308
|
+
cacheContext,
|
|
44
309
|
)
|
|
45
310
|
const orderResult = await applyResponseEnricherToRecord(
|
|
46
311
|
{ id: 'shared-id' },
|
|
47
312
|
'sales.order',
|
|
48
|
-
|
|
313
|
+
cacheContext,
|
|
49
314
|
)
|
|
50
315
|
|
|
51
316
|
expect(personResult.record).toMatchObject({ enrichedFrom: 'customers.person' })
|