@open-mercato/shared 0.6.7 → 0.6.8-canary.320.1.bf889f49e4
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 +62 -6
- package/build.mjs +2 -4
- package/dist/lib/ai/safety-identifier.js +32 -0
- package/dist/lib/ai/safety-identifier.js.map +7 -0
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/auth/server.js +15 -2
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
- package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +190 -70
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +18 -13
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/commands/command-bus.js +9 -3
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +9 -4
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/commands/errors.js +25 -4
- package/dist/lib/commands/errors.js.map +2 -2
- package/dist/lib/commands/index.js +7 -1
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +2 -11
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +104 -23
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-runner.js +2 -2
- package/dist/lib/crud/interceptor-runner.js.map +2 -2
- package/dist/lib/crud/mutation-guard-registry.js +2 -2
- package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
- package/dist/lib/crud/types.js +4 -0
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/consistency.js +19 -0
- package/dist/lib/data/consistency.js.map +7 -0
- package/dist/lib/data/engine.js +37 -11
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/entityMetadata.js +26 -0
- package/dist/lib/db/entityMetadata.js.map +7 -0
- package/dist/lib/db/pg-errors.js +42 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +48 -6
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +56 -0
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/find.js +22 -0
- package/dist/lib/encryption/find.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +66 -0
- package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +2 -10
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/config.js +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/dictionary-cache.js +5 -0
- package/dist/lib/i18n/dictionary-cache.js.map +2 -2
- package/dist/lib/i18n/server.js +4 -4
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/logger/extension.js +29 -0
- package/dist/lib/logger/extension.js.map +7 -0
- package/dist/lib/logger/index.js +47 -3
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +36 -6
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/modules/surfaceFingerprint.js +47 -0
- package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
- package/dist/lib/query/ciphertext-search-warning.js +51 -0
- package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
- package/dist/lib/query/engine.js +60 -43
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +7 -2
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/auto-indexing.js +14 -0
- package/dist/lib/search/auto-indexing.js.map +7 -0
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/search/config.js +61 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenLookup.js +46 -0
- package/dist/lib/search/tokenLookup.js.map +7 -0
- package/dist/lib/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/telemetry/runtime.js +35 -0
- package/dist/lib/telemetry/runtime.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +57 -0
- package/dist/lib/webhooks/body.js.map +7 -0
- package/dist/lib/webhooks/inbound-types.js +1 -0
- package/dist/lib/webhooks/inbound-types.js.map +7 -0
- package/dist/lib/webhooks/index.js +13 -1
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +12 -7
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +50 -1
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +8 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +1 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/extension-points.js +96 -0
- package/dist/modules/widgets/extension-points.js.map +7 -0
- package/dist/security/enabledModulesRegistry.js +56 -11
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/dist/security/featurePolicy.js +62 -0
- package/dist/security/featurePolicy.js.map +7 -0
- package/jest.config.cjs +20 -10
- package/package.json +19 -9
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +64 -0
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
- package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
- package/src/lib/ai/llm-provider.ts +33 -0
- package/src/lib/ai/safety-identifier.ts +73 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/__tests__/server.test.ts +23 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/auth/server.ts +23 -2
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.test.ts +126 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
- package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +346 -96
- package/src/lib/bootstrap/factory.ts +21 -15
- package/src/lib/bootstrap/types.ts +6 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +63 -0
- package/src/lib/commands/__tests__/command-interceptor-error.test.ts +91 -1
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +101 -0
- package/src/lib/commands/command-bus.ts +9 -3
- package/src/lib/commands/command-interceptor-runner.ts +27 -6
- package/src/lib/commands/command-interceptor.ts +8 -0
- package/src/lib/commands/errors.ts +67 -2
- package/src/lib/commands/index.ts +7 -1
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +564 -0
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
- package/src/lib/crud/enricher-runner.ts +2 -11
- package/src/lib/crud/factory.ts +157 -22
- package/src/lib/crud/interceptor-runner.ts +2 -2
- package/src/lib/crud/mutation-guard-registry.ts +2 -2
- package/src/lib/crud/types.ts +3 -0
- package/src/lib/data/__tests__/consistency.test.ts +38 -0
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
- package/src/lib/data/consistency.ts +17 -0
- package/src/lib/data/engine.ts +50 -16
- package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
- package/src/lib/db/entityMetadata.ts +43 -0
- package/src/lib/db/pg-errors.ts +58 -0
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
- package/src/lib/di/container.ts +72 -8
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -0
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +167 -0
- package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
- package/src/lib/encryption/find.ts +22 -0
- package/src/lib/encryption/likeFilterWarning.ts +102 -0
- package/src/lib/encryption/subscriber.ts +2 -14
- package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
- package/src/lib/i18n/__tests__/config.test.ts +8 -0
- package/src/lib/i18n/__tests__/dictionary-cache.test.ts +23 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/dictionary-cache.ts +5 -0
- package/src/lib/i18n/server.ts +6 -2
- package/src/lib/logger/__tests__/logger.test.ts +77 -0
- package/src/lib/logger/extension.ts +59 -0
- package/src/lib/logger/index.ts +59 -1
- package/src/lib/modules/__tests__/registry.test.ts +26 -0
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/registry.ts +46 -7
- package/src/lib/modules/surfaceFingerprint.ts +87 -0
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +67 -0
- package/src/lib/query/__tests__/engine.test.ts +213 -13
- package/src/lib/query/ciphertext-search-warning.ts +106 -0
- package/src/lib/query/engine.ts +102 -61
- package/src/lib/query/types.ts +4 -5
- package/src/lib/redis/__tests__/connection.test.ts +16 -0
- package/src/lib/redis/connection.ts +10 -7
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/__tests__/config.test.ts +177 -0
- package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/auto-indexing.ts +22 -0
- package/src/lib/search/availability.ts +232 -0
- package/src/lib/search/config.ts +106 -8
- package/src/lib/search/tokenLookup.ts +133 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/telemetry/runtime.ts +72 -0
- package/src/lib/webhooks/__tests__/body.test.ts +95 -0
- package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
- package/src/lib/webhooks/body.ts +70 -0
- package/src/lib/webhooks/inbound-types.ts +125 -0
- package/src/lib/webhooks/index.ts +19 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
- package/src/modules/__tests__/registry.test.ts +10 -2
- package/src/modules/analytics.ts +7 -0
- package/src/modules/customer-auth.ts +1 -0
- package/src/modules/encryption.ts +3 -0
- package/src/modules/entities.ts +15 -0
- package/src/modules/navigation/backendChrome.ts +15 -0
- package/src/modules/overrides.ts +103 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +41 -0
- package/src/modules/registry.ts +3 -0
- package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
- package/src/modules/widgets/extension-points.ts +524 -0
- package/src/security/__tests__/featurePolicy.test.ts +166 -0
- package/src/security/enabledModulesRegistry.ts +64 -12
- package/src/security/featurePolicy.ts +89 -0
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
registerOptimisticLockReaders,
|
|
11
11
|
} from '@open-mercato/shared/lib/crud/optimistic-lock-store'
|
|
12
12
|
import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
13
|
+
import { registerMutationGuards } from '@open-mercato/shared/lib/crud/mutation-guard-store'
|
|
14
|
+
import { CommandInterceptorError } from '@open-mercato/shared/lib/commands/errors'
|
|
13
15
|
import { z } from 'zod'
|
|
14
16
|
|
|
15
17
|
// Keep the real custom-field helpers but spy on the definition loader so we can
|
|
@@ -201,6 +203,7 @@ describe('CRUD Factory', () => {
|
|
|
201
203
|
}
|
|
202
204
|
crudMutationGuardService = null
|
|
203
205
|
registerApiInterceptors([])
|
|
206
|
+
registerMutationGuards([])
|
|
204
207
|
})
|
|
205
208
|
|
|
206
209
|
const querySchema = z.object({
|
|
@@ -371,6 +374,107 @@ describe('CRUD Factory', () => {
|
|
|
371
374
|
])
|
|
372
375
|
})
|
|
373
376
|
|
|
377
|
+
// Routes that delegate the fallback order to `list.defaultSort` leave `sortField`
|
|
378
|
+
// optional; a zod `.default()` would make every request look explicitly sorted.
|
|
379
|
+
const sortableQuerySchema = z.object({
|
|
380
|
+
page: z.coerce.number().default(1),
|
|
381
|
+
pageSize: z.coerce.number().default(50),
|
|
382
|
+
sortField: z.string().optional(),
|
|
383
|
+
sortDir: z.enum(['asc', 'desc']).optional(),
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
const makeSortedRoute = (list?: Partial<Parameters<typeof makeCrudRoute>[0]['list']>) => makeCrudRoute({
|
|
387
|
+
metadata: { GET: { requireAuth: true } },
|
|
388
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
389
|
+
indexer: { entityType: 'example.todo' },
|
|
390
|
+
list: {
|
|
391
|
+
schema: sortableQuerySchema,
|
|
392
|
+
entityId: 'example.todo',
|
|
393
|
+
fields: ['id', 'title'],
|
|
394
|
+
sortFieldMap: { id: 'id', title: 'title', lineNumber: 'line_number' },
|
|
395
|
+
buildFilters: () => ({} as any),
|
|
396
|
+
disableListCache: true,
|
|
397
|
+
...list,
|
|
398
|
+
} as any,
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('GET falls back to sorting by id when no default sort is configured', async () => {
|
|
402
|
+
await makeSortedRoute().GET(new Request('http://x/api/example/todos?page=1&pageSize=10'))
|
|
403
|
+
|
|
404
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
405
|
+
expect(queryArgs?.sort).toEqual([{ field: 'id', dir: 'asc' }])
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('GET applies list.defaultSort through sortFieldMap when the request omits a sort', async () => {
|
|
409
|
+
const sortedRoute = makeSortedRoute({
|
|
410
|
+
defaultSort: { field: 'lineNumber', dir: 'asc' },
|
|
411
|
+
tiebreakSortField: 'id',
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10'))
|
|
415
|
+
|
|
416
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
417
|
+
expect(queryArgs?.sort).toEqual([
|
|
418
|
+
{ field: 'line_number', dir: 'asc' },
|
|
419
|
+
{ field: 'id', dir: 'asc' },
|
|
420
|
+
])
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('GET honours an explicit sort over list.defaultSort and keeps the tiebreak', async () => {
|
|
424
|
+
const sortedRoute = makeSortedRoute({
|
|
425
|
+
defaultSort: { field: 'lineNumber', dir: 'asc' },
|
|
426
|
+
tiebreakSortField: 'id',
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=title&sortDir=desc'))
|
|
430
|
+
|
|
431
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
432
|
+
expect(queryArgs?.sort).toEqual([
|
|
433
|
+
{ field: 'title', dir: 'desc' },
|
|
434
|
+
{ field: 'id', dir: 'asc' },
|
|
435
|
+
])
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('GET keeps an explicit sort ascending by default even when list.defaultSort is descending', async () => {
|
|
439
|
+
const sortedRoute = makeSortedRoute({ defaultSort: { field: 'title', dir: 'desc' } })
|
|
440
|
+
|
|
441
|
+
await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id'))
|
|
442
|
+
|
|
443
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
444
|
+
expect(queryArgs?.sort).toEqual([{ field: 'id', dir: 'asc' }])
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('GET treats a blank sortField as absent and falls back to list.defaultSort', async () => {
|
|
448
|
+
const sortedRoute = makeSortedRoute({
|
|
449
|
+
defaultSort: { field: 'lineNumber', dir: 'asc' },
|
|
450
|
+
tiebreakSortField: 'id',
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField='))
|
|
454
|
+
|
|
455
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
456
|
+
expect(queryArgs?.sort).toEqual([
|
|
457
|
+
{ field: 'line_number', dir: 'asc' },
|
|
458
|
+
{ field: 'id', dir: 'asc' },
|
|
459
|
+
])
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('GET keeps falling back to id for a blank sortField when no default is configured', async () => {
|
|
463
|
+
await makeSortedRoute().GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=&sortDir=desc'))
|
|
464
|
+
|
|
465
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
466
|
+
expect(queryArgs?.sort).toEqual([{ field: 'id', dir: 'desc' }])
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('GET does not duplicate the tiebreak when it matches the primary sort', async () => {
|
|
470
|
+
const sortedRoute = makeSortedRoute({ tiebreakSortField: 'id' })
|
|
471
|
+
|
|
472
|
+
await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=desc'))
|
|
473
|
+
|
|
474
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
475
|
+
expect(queryArgs?.sort).toEqual([{ field: 'id', dir: 'desc' }])
|
|
476
|
+
})
|
|
477
|
+
|
|
374
478
|
it('GET intersects ids with existing buildFilters id constraint', async () => {
|
|
375
479
|
const routeWithIdFilter = makeCrudRoute({
|
|
376
480
|
metadata: { GET: { requireAuth: true } },
|
|
@@ -481,6 +585,45 @@ describe('CRUD Factory', () => {
|
|
|
481
585
|
expect(ids).toContain(otherTenant.id)
|
|
482
586
|
})
|
|
483
587
|
|
|
588
|
+
it('GET resolves function-form CSV headers and rows per request', async () => {
|
|
589
|
+
// Regression coverage for the additive `(query, ctx)` form of `ListConfig.csv`.
|
|
590
|
+
// A route whose export columns depend on per-request discovery (custom-field keys,
|
|
591
|
+
// for example) must resolve them from the request context, never from module-level
|
|
592
|
+
// state that the previous request left behind.
|
|
593
|
+
const perRequestColumns = new WeakMap<object, string>()
|
|
594
|
+
const dynamicCsvRoute = makeCrudRoute({
|
|
595
|
+
metadata: { GET: { requireAuth: true } },
|
|
596
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
597
|
+
indexer: { entityType: 'example.todo' },
|
|
598
|
+
list: {
|
|
599
|
+
schema: querySchema,
|
|
600
|
+
entityId: 'example.todo',
|
|
601
|
+
fields: ['id', 'title', 'is_done'],
|
|
602
|
+
sortFieldMap: { id: 'id' },
|
|
603
|
+
buildFilters: () => ({} as any),
|
|
604
|
+
transformItem: (item: any) => ({ id: item.id, title: item.title }),
|
|
605
|
+
allowCsv: true,
|
|
606
|
+
csv: {
|
|
607
|
+
headers: (_query, ctx) => ['id', perRequestColumns.get(ctx) ?? 'fallback'],
|
|
608
|
+
row: (item: any, ctx) => [item.id, `${perRequestColumns.get(ctx) ?? 'fallback'}:${item.title}`],
|
|
609
|
+
filename: 'dynamic.csv',
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
hooks: {
|
|
613
|
+
beforeList: (_query, ctx) => {
|
|
614
|
+
const column = new URL(ctx.request!.url).searchParams.get('column')
|
|
615
|
+
perRequestColumns.set(ctx, column ?? 'fallback')
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
const first = await dynamicCsvRoute.GET(new Request('http://x/api/example/todos?format=csv&column=alpha'))
|
|
621
|
+
const second = await dynamicCsvRoute.GET(new Request('http://x/api/example/todos?format=csv&column=beta'))
|
|
622
|
+
|
|
623
|
+
expect((await first.text()).split('\n')[0]).toBe('id,alpha')
|
|
624
|
+
expect((await second.text()).split('\n')[0]).toBe('id,beta')
|
|
625
|
+
})
|
|
626
|
+
|
|
484
627
|
it('GET returns CSV when format=csv', async () => {
|
|
485
628
|
const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc&format=csv'))
|
|
486
629
|
expect(res.headers.get('content-type')).toContain('text/csv')
|
|
@@ -537,12 +680,66 @@ describe('CRUD Factory', () => {
|
|
|
537
680
|
})
|
|
538
681
|
})
|
|
539
682
|
|
|
683
|
+
describe('export loop termination', () => {
|
|
684
|
+
const EXPORT_PAGE_SIZE = 1000
|
|
685
|
+
|
|
686
|
+
afterEach(() => {
|
|
687
|
+
queryEngine.query.mockImplementation(async (_entityId: any, _q: any) => ({ items: [{ id: 'id-1', title: 'A', is_done: false, organization_id: defaultOrganizationId, tenant_id: defaultTenantId }], total: 1 }))
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
const makeItems = (count: number, offset = 0) =>
|
|
691
|
+
Array.from({ length: count }, (_, index) => ({
|
|
692
|
+
id: `id-${offset + index}`,
|
|
693
|
+
title: `Todo ${offset + index}`,
|
|
694
|
+
is_done: false,
|
|
695
|
+
organization_id: defaultOrganizationId,
|
|
696
|
+
tenant_id: defaultTenantId,
|
|
697
|
+
}))
|
|
698
|
+
|
|
699
|
+
const queuePages = (pages: Array<Array<Record<string, unknown>>>, total: number) => {
|
|
700
|
+
queryEngine.query.mockImplementation(async (_entityId: any, q: any) => {
|
|
701
|
+
const page = q?.page?.page ?? 1
|
|
702
|
+
return { items: pages[page - 1] ?? [], total }
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
it('GET export enumerates every page even when total under-reports the result set', async () => {
|
|
707
|
+
queuePages(
|
|
708
|
+
[makeItems(EXPORT_PAGE_SIZE), makeItems(EXPORT_PAGE_SIZE, EXPORT_PAGE_SIZE), makeItems(5, EXPORT_PAGE_SIZE * 2)],
|
|
709
|
+
3,
|
|
710
|
+
)
|
|
711
|
+
const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
|
|
712
|
+
expect(res.status).toBe(200)
|
|
713
|
+
const parsed = JSON.parse(await res.text())
|
|
714
|
+
expect(parsed).toHaveLength(EXPORT_PAGE_SIZE * 2 + 5)
|
|
715
|
+
expect(queryEngine.query).toHaveBeenCalledTimes(3)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it('GET export terminates on a short final page instead of trusting an inflated total', async () => {
|
|
719
|
+
queuePages([makeItems(4)], 10_000)
|
|
720
|
+
const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
|
|
721
|
+
expect(res.status).toBe(200)
|
|
722
|
+
const parsed = JSON.parse(await res.text())
|
|
723
|
+
expect(parsed).toHaveLength(4)
|
|
724
|
+
expect(queryEngine.query).toHaveBeenCalledTimes(1)
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it('GET export fails closed at the page ceiling rather than serializing a partial export', async () => {
|
|
728
|
+
const fullPage = makeItems(EXPORT_PAGE_SIZE)
|
|
729
|
+
queryEngine.query.mockImplementation(async () => ({ items: fullPage, total: EXPORT_PAGE_SIZE }))
|
|
730
|
+
const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
|
|
731
|
+
expect(res.status).toBe(500)
|
|
732
|
+
expect(queryEngine.query).toHaveBeenCalledTimes(1000)
|
|
733
|
+
})
|
|
734
|
+
})
|
|
735
|
+
|
|
540
736
|
it('POST creates entity, saves custom fields, emits created event', async () => {
|
|
541
737
|
const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'B', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
|
|
542
738
|
expect(res.status).toBe(201)
|
|
543
739
|
const data = await res.json()
|
|
544
740
|
expect(data.id).toBeDefined()
|
|
545
741
|
// CF saved
|
|
742
|
+
expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
|
|
546
743
|
expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ entityId: 'example.todo', values: { priority: 3 } }))
|
|
547
744
|
// Event + indexer delegated to data engine
|
|
548
745
|
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
@@ -569,6 +766,7 @@ describe('CRUD Factory', () => {
|
|
|
569
766
|
await em.persist(created).flush()
|
|
570
767
|
const res = await route.PUT(new Request('http://x/api/example/todos', { method: 'PUT', body: JSON.stringify({ id: created.id, title: 'X2', cf_priority: 5 }), headers: { 'content-type': 'application/json' } }))
|
|
571
768
|
expect(res.status).toBe(200)
|
|
769
|
+
expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
|
|
572
770
|
expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ values: { priority: 5 } }))
|
|
573
771
|
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
574
772
|
const updatedCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
|
|
@@ -590,6 +788,34 @@ describe('CRUD Factory', () => {
|
|
|
590
788
|
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
591
789
|
})
|
|
592
790
|
|
|
791
|
+
it('returns a retryable 503 when a handler hits a transient DB connection failure', async () => {
|
|
792
|
+
setRecordCustomFields.mockImplementationOnce(async () => {
|
|
793
|
+
throw Object.assign(new Error('sorry, too many clients already'), { code: '53300' })
|
|
794
|
+
})
|
|
795
|
+
const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'Exhausted', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
|
|
796
|
+
expect(res.status).toBe(503)
|
|
797
|
+
expect(res.headers.get('Retry-After')).toBe('2')
|
|
798
|
+
// The failed write is still rolled back — no created event/index leaks out.
|
|
799
|
+
expect(Object.values(db)).toHaveLength(0)
|
|
800
|
+
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
it('POST surfaces CRUD side-effect failures after custom field writes', async () => {
|
|
804
|
+
mockDataEngine.emitOrmEntityEvent.mockImplementationOnce(async () => {
|
|
805
|
+
throw new Error('index write failed')
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
const res = await route.POST(new Request('http://x/api/example/todos', {
|
|
809
|
+
method: 'POST',
|
|
810
|
+
body: JSON.stringify({ title: 'Indexed', is_done: true, cf_priority: 3 }),
|
|
811
|
+
headers: { 'content-type': 'application/json' },
|
|
812
|
+
}))
|
|
813
|
+
|
|
814
|
+
expect(res.status).toBe(500)
|
|
815
|
+
expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
|
|
816
|
+
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
817
|
+
})
|
|
818
|
+
|
|
593
819
|
it('PUT rolls back the entity update when the custom field write fails', async () => {
|
|
594
820
|
const created = em.create(Todo, { title: 'Before', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
|
|
595
821
|
created.id = '123e4567-e89b-12d3-a456-426614174003'
|
|
@@ -721,6 +947,265 @@ describe('CRUD Factory', () => {
|
|
|
721
947
|
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
722
948
|
})
|
|
723
949
|
|
|
950
|
+
it('POST command route runs mutation guards before executing the command', async () => {
|
|
951
|
+
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'Blocked by test guard' }))
|
|
952
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
953
|
+
id: 'example.block-command-create',
|
|
954
|
+
targetEntity: 'example.todo',
|
|
955
|
+
operations: ['create'],
|
|
956
|
+
validate: guardValidate,
|
|
957
|
+
}] }])
|
|
958
|
+
const commandRoute = makeCrudRoute({
|
|
959
|
+
metadata: { POST: { requireAuth: true } },
|
|
960
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
961
|
+
indexer: { entityType: 'example.todo' },
|
|
962
|
+
actions: {
|
|
963
|
+
create: {
|
|
964
|
+
commandId: 'example.todo.create',
|
|
965
|
+
schema: createSchema,
|
|
966
|
+
response: () => ({ ok: true }),
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
|
|
972
|
+
method: 'POST',
|
|
973
|
+
body: JSON.stringify({ title: 'A' }),
|
|
974
|
+
headers: { 'content-type': 'application/json' },
|
|
975
|
+
}))
|
|
976
|
+
|
|
977
|
+
expect(res.status).toBe(403)
|
|
978
|
+
expect(guardValidate).toHaveBeenCalledWith(expect.objectContaining({
|
|
979
|
+
resourceKind: 'example.todo',
|
|
980
|
+
resourceId: null,
|
|
981
|
+
operation: 'create',
|
|
982
|
+
mutationPayload: expect.objectContaining({ title: 'A' }),
|
|
983
|
+
}))
|
|
984
|
+
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
it('POST command route merges guard modifiedPayload and runs afterSuccess with the command result id', async () => {
|
|
988
|
+
commandBus.execute.mockResolvedValue({ result: { id: 'cmd-created-1' }, logEntry: { id: 'log-1' } })
|
|
989
|
+
const guardAfterSuccess = jest.fn(async () => {})
|
|
990
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
991
|
+
id: 'example.rewrite-command-create',
|
|
992
|
+
targetEntity: 'example.todo',
|
|
993
|
+
operations: ['create'],
|
|
994
|
+
validate: async (_input: any) => ({ ok: true, modifiedPayload: { title: 'FROM-GUARD' }, shouldRunAfterSuccess: true }),
|
|
995
|
+
afterSuccess: guardAfterSuccess,
|
|
996
|
+
}] }])
|
|
997
|
+
const commandRoute = makeCrudRoute({
|
|
998
|
+
metadata: { POST: { requireAuth: true } },
|
|
999
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
1000
|
+
indexer: { entityType: 'example.todo' },
|
|
1001
|
+
actions: {
|
|
1002
|
+
create: {
|
|
1003
|
+
commandId: 'example.todo.create',
|
|
1004
|
+
schema: createSchema,
|
|
1005
|
+
response: () => ({ ok: true }),
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
|
|
1011
|
+
method: 'POST',
|
|
1012
|
+
body: JSON.stringify({ title: 'A' }),
|
|
1013
|
+
headers: { 'content-type': 'application/json' },
|
|
1014
|
+
}))
|
|
1015
|
+
|
|
1016
|
+
expect(res.status).toBe(201)
|
|
1017
|
+
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.create', expect.objectContaining({
|
|
1018
|
+
input: expect.objectContaining({ title: 'FROM-GUARD' }),
|
|
1019
|
+
}))
|
|
1020
|
+
expect(guardAfterSuccess).toHaveBeenCalledWith(expect.objectContaining({
|
|
1021
|
+
resourceId: 'cmd-created-1',
|
|
1022
|
+
operation: 'create',
|
|
1023
|
+
}))
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
// Issue #5045 — a deliberate interceptor rejection must not be laundered into a generic 500.
|
|
1027
|
+
const interceptorErrorRoute = () => makeCrudRoute({
|
|
1028
|
+
metadata: { POST: { requireAuth: true } },
|
|
1029
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
1030
|
+
indexer: { entityType: 'example.todo' },
|
|
1031
|
+
actions: {
|
|
1032
|
+
create: {
|
|
1033
|
+
commandId: 'example.todo.create',
|
|
1034
|
+
schema: createSchema,
|
|
1035
|
+
response: () => ({ ok: true }),
|
|
1036
|
+
},
|
|
1037
|
+
},
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
const postInterceptorErrorRequest = (route: ReturnType<typeof interceptorErrorRoute>) => route.POST(
|
|
1041
|
+
new Request('http://x/api/example/todos/command', {
|
|
1042
|
+
method: 'POST',
|
|
1043
|
+
body: JSON.stringify({ title: 'A' }),
|
|
1044
|
+
headers: { 'content-type': 'application/json' },
|
|
1045
|
+
}),
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
it('POST command route keeps the generic 500 when an interceptor blocks without a status', async () => {
|
|
1049
|
+
commandBus.execute.mockRejectedValue(new CommandInterceptorError('Missing required fields: VAT id'))
|
|
1050
|
+
|
|
1051
|
+
const res = await postInterceptorErrorRequest(interceptorErrorRoute())
|
|
1052
|
+
|
|
1053
|
+
expect(res.status).toBe(500)
|
|
1054
|
+
await expect(res.json()).resolves.toEqual({
|
|
1055
|
+
error: 'Internal server error',
|
|
1056
|
+
message: 'Something went wrong. Please try again later.',
|
|
1057
|
+
})
|
|
1058
|
+
})
|
|
1059
|
+
|
|
1060
|
+
it('POST command route surfaces the interceptor status and message when the block carries a status', async () => {
|
|
1061
|
+
commandBus.execute.mockRejectedValue(
|
|
1062
|
+
new CommandInterceptorError('Missing required fields: VAT id', { status: 422 }),
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
const res = await postInterceptorErrorRequest(interceptorErrorRoute())
|
|
1066
|
+
|
|
1067
|
+
expect(res.status).toBe(422)
|
|
1068
|
+
await expect(res.json()).resolves.toEqual({ error: 'Missing required fields: VAT id' })
|
|
1069
|
+
})
|
|
1070
|
+
|
|
1071
|
+
it('POST command route surfaces the interceptor body verbatim when one is supplied', async () => {
|
|
1072
|
+
commandBus.execute.mockRejectedValue(
|
|
1073
|
+
new CommandInterceptorError('Blocked', { status: 422, body: { error: 'Blocked', missingFields: ['vatId'] } }),
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
const res = await postInterceptorErrorRequest(interceptorErrorRoute())
|
|
1077
|
+
|
|
1078
|
+
expect(res.status).toBe(422)
|
|
1079
|
+
await expect(res.json()).resolves.toEqual({ error: 'Blocked', missingFields: ['vatId'] })
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
it('POST command route keeps the generic 500 when the interceptor status is outside 4xx/5xx', async () => {
|
|
1083
|
+
// A status the Response constructor would reject (or that would report a block as success)
|
|
1084
|
+
// must not escape handleError as a RangeError — it falls back to the generic 500 instead.
|
|
1085
|
+
commandBus.execute.mockRejectedValue(
|
|
1086
|
+
Object.assign(new CommandInterceptorError('Blocked'), { status: 600, body: { error: 'Blocked' } }),
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
const res = await postInterceptorErrorRequest(interceptorErrorRoute())
|
|
1090
|
+
|
|
1091
|
+
expect(res.status).toBe(500)
|
|
1092
|
+
await expect(res.json()).resolves.toEqual({
|
|
1093
|
+
error: 'Internal server error',
|
|
1094
|
+
message: 'Something went wrong. Please try again later.',
|
|
1095
|
+
})
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
it('POST command route falls back to the response payload id for guard afterSuccess', async () => {
|
|
1099
|
+
commandBus.execute.mockResolvedValue({ result: { lineId: 'line-42' }, logEntry: { id: 'log-1' } })
|
|
1100
|
+
const guardAfterSuccess = jest.fn(async () => {})
|
|
1101
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
1102
|
+
id: 'example.after-command-create',
|
|
1103
|
+
targetEntity: 'example.todo',
|
|
1104
|
+
operations: ['create'],
|
|
1105
|
+
validate: async (_input: any) => ({ ok: true, shouldRunAfterSuccess: true }),
|
|
1106
|
+
afterSuccess: guardAfterSuccess,
|
|
1107
|
+
}] }])
|
|
1108
|
+
const commandRoute = makeCrudRoute({
|
|
1109
|
+
metadata: { POST: { requireAuth: true } },
|
|
1110
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
1111
|
+
indexer: { entityType: 'example.todo' },
|
|
1112
|
+
actions: {
|
|
1113
|
+
create: {
|
|
1114
|
+
commandId: 'example.todo.create',
|
|
1115
|
+
schema: createSchema,
|
|
1116
|
+
response: ({ result }: any) => ({ id: result.lineId }),
|
|
1117
|
+
},
|
|
1118
|
+
},
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
|
|
1122
|
+
method: 'POST',
|
|
1123
|
+
body: JSON.stringify({ title: 'A' }),
|
|
1124
|
+
headers: { 'content-type': 'application/json' },
|
|
1125
|
+
}))
|
|
1126
|
+
|
|
1127
|
+
expect(res.status).toBe(201)
|
|
1128
|
+
expect(guardAfterSuccess).toHaveBeenCalledWith(expect.objectContaining({
|
|
1129
|
+
resourceId: 'line-42',
|
|
1130
|
+
operation: 'create',
|
|
1131
|
+
}))
|
|
1132
|
+
})
|
|
1133
|
+
|
|
1134
|
+
// Commands whose mapInput wraps the payload (e.g. `{ body }`) null the factory
|
|
1135
|
+
// candidateId and thereby OPT OUT of row-level mutation guards, leaving the
|
|
1136
|
+
// command-level optimistic-lock check as the sole guard — a documented contract
|
|
1137
|
+
// (apps/docs/docs/framework/data-integrity/concurrency-locking.mdx) that sales
|
|
1138
|
+
// line/adjustment routes rely on.
|
|
1139
|
+
it('PUT command route without a top-level id keeps the documented row-level guard opt-out', async () => {
|
|
1140
|
+
crudMutationGuardService = {
|
|
1141
|
+
validateMutation: jest.fn().mockResolvedValue({ ok: true, shouldRunAfterSuccess: false }),
|
|
1142
|
+
afterMutationSuccess: jest.fn().mockResolvedValue(undefined),
|
|
1143
|
+
}
|
|
1144
|
+
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 409, message: 'must not run' }))
|
|
1145
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
1146
|
+
id: 'example.idless-update-opt-out',
|
|
1147
|
+
targetEntity: 'example.todo',
|
|
1148
|
+
operations: ['update'],
|
|
1149
|
+
validate: guardValidate,
|
|
1150
|
+
}] }])
|
|
1151
|
+
const commandRoute = makeCrudRoute({
|
|
1152
|
+
metadata: { PUT: { requireAuth: true } },
|
|
1153
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
1154
|
+
indexer: { entityType: 'example.todo' },
|
|
1155
|
+
actions: {
|
|
1156
|
+
update: {
|
|
1157
|
+
commandId: 'example.todo.update',
|
|
1158
|
+
schema: z.object({ title: z.string() }),
|
|
1159
|
+
mapInput: ({ parsed }: any) => ({ body: parsed }),
|
|
1160
|
+
response: () => ({ ok: true }),
|
|
1161
|
+
},
|
|
1162
|
+
},
|
|
1163
|
+
})
|
|
1164
|
+
|
|
1165
|
+
const res = await commandRoute.PUT(new Request('http://x/api/example/todos/command', {
|
|
1166
|
+
method: 'PUT',
|
|
1167
|
+
body: JSON.stringify({ title: 'nested id shape' }),
|
|
1168
|
+
headers: { 'content-type': 'application/json' },
|
|
1169
|
+
}))
|
|
1170
|
+
|
|
1171
|
+
expect(res.status).toBe(200)
|
|
1172
|
+
expect(guardValidate).not.toHaveBeenCalled()
|
|
1173
|
+
expect(crudMutationGuardService.validateMutation).not.toHaveBeenCalled()
|
|
1174
|
+
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.update', expect.anything())
|
|
1175
|
+
})
|
|
1176
|
+
|
|
1177
|
+
it('DELETE command route without any id keeps the documented row-level guard opt-out', async () => {
|
|
1178
|
+
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'must not run' }))
|
|
1179
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
1180
|
+
id: 'example.idless-delete-opt-out',
|
|
1181
|
+
targetEntity: 'example.todo',
|
|
1182
|
+
operations: ['delete'],
|
|
1183
|
+
validate: guardValidate,
|
|
1184
|
+
}] }])
|
|
1185
|
+
const commandRoute = makeCrudRoute({
|
|
1186
|
+
metadata: { DELETE: { requireAuth: true } },
|
|
1187
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
1188
|
+
indexer: { entityType: 'example.todo' },
|
|
1189
|
+
actions: {
|
|
1190
|
+
delete: {
|
|
1191
|
+
commandId: 'example.todo.delete',
|
|
1192
|
+
schema: z.any(),
|
|
1193
|
+
response: () => ({ ok: true }),
|
|
1194
|
+
},
|
|
1195
|
+
},
|
|
1196
|
+
})
|
|
1197
|
+
|
|
1198
|
+
const res = await commandRoute.DELETE(new Request('http://x/api/example/todos/command', {
|
|
1199
|
+
method: 'DELETE',
|
|
1200
|
+
body: JSON.stringify({}),
|
|
1201
|
+
headers: { 'content-type': 'application/json' },
|
|
1202
|
+
}))
|
|
1203
|
+
|
|
1204
|
+
expect(res.status).toBe(200)
|
|
1205
|
+
expect(guardValidate).not.toHaveBeenCalled()
|
|
1206
|
+
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.delete', expect.anything())
|
|
1207
|
+
})
|
|
1208
|
+
|
|
724
1209
|
it('POST is blocked by interceptor before hook', async () => {
|
|
725
1210
|
registerApiInterceptors([
|
|
726
1211
|
{
|
|
@@ -784,6 +1269,85 @@ describe('CRUD Factory', () => {
|
|
|
784
1269
|
const body = await res.json()
|
|
785
1270
|
expect(body._interceptor).toEqual({ ok: true, count: 1 })
|
|
786
1271
|
})
|
|
1272
|
+
|
|
1273
|
+
// The command DELETE path used to hand before-interceptors the body only, so a
|
|
1274
|
+
// guard reading `?id=` saw nothing to object to and the delete went through
|
|
1275
|
+
// with a 200 (issue #4842).
|
|
1276
|
+
it('DELETE command route passes the query id to interceptor before hooks', async () => {
|
|
1277
|
+
const lockedId = '123e4567-e89b-12d3-a456-426614174010'
|
|
1278
|
+
const seenQueries: Array<Record<string, unknown> | undefined> = []
|
|
1279
|
+
registerApiInterceptors([
|
|
1280
|
+
{
|
|
1281
|
+
moduleId: 'example',
|
|
1282
|
+
interceptors: [
|
|
1283
|
+
{
|
|
1284
|
+
id: 'example.block-locked-delete',
|
|
1285
|
+
targetRoute: 'example/todos/command',
|
|
1286
|
+
methods: ['DELETE'],
|
|
1287
|
+
async before(request) {
|
|
1288
|
+
seenQueries.push(request.query)
|
|
1289
|
+
const queryId = request.query?.id
|
|
1290
|
+
if (typeof queryId === 'string' && queryId === lockedId) {
|
|
1291
|
+
return { ok: false, statusCode: 409, message: 'Record is locked' }
|
|
1292
|
+
}
|
|
1293
|
+
return { ok: true }
|
|
1294
|
+
},
|
|
1295
|
+
},
|
|
1296
|
+
],
|
|
1297
|
+
},
|
|
1298
|
+
])
|
|
1299
|
+
const commandRoute = makeCrudRoute({
|
|
1300
|
+
metadata: { DELETE: { requireAuth: true } },
|
|
1301
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
1302
|
+
indexer: { entityType: 'example.todo' },
|
|
1303
|
+
actions: {
|
|
1304
|
+
delete: {
|
|
1305
|
+
commandId: 'example.todo.delete',
|
|
1306
|
+
schema: z.any(),
|
|
1307
|
+
response: () => ({ ok: true }),
|
|
1308
|
+
},
|
|
1309
|
+
},
|
|
1310
|
+
})
|
|
1311
|
+
|
|
1312
|
+
const res = await commandRoute.DELETE(new Request(`http://x/api/example/todos/command?id=${lockedId}`, {
|
|
1313
|
+
method: 'DELETE',
|
|
1314
|
+
body: JSON.stringify({}),
|
|
1315
|
+
headers: { 'content-type': 'application/json' },
|
|
1316
|
+
}))
|
|
1317
|
+
|
|
1318
|
+
expect(res.status).toBe(409)
|
|
1319
|
+
expect(await res.json()).toMatchObject({ error: 'Record is locked' })
|
|
1320
|
+
expect(seenQueries).toEqual([{ id: lockedId }])
|
|
1321
|
+
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
1322
|
+
})
|
|
1323
|
+
|
|
1324
|
+
it('DELETE route passes the whole query string to interceptor before hooks', async () => {
|
|
1325
|
+
const created = em.create(Todo, { title: 'Z', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
|
|
1326
|
+
created.id = '123e4567-e89b-12d3-a456-426614174011'
|
|
1327
|
+
await em.persist(created).flush()
|
|
1328
|
+
let seenQuery: Record<string, unknown> | undefined
|
|
1329
|
+
registerApiInterceptors([
|
|
1330
|
+
{
|
|
1331
|
+
moduleId: 'example',
|
|
1332
|
+
interceptors: [
|
|
1333
|
+
{
|
|
1334
|
+
id: 'example.capture-delete-query',
|
|
1335
|
+
targetRoute: 'example/todos',
|
|
1336
|
+
methods: ['DELETE'],
|
|
1337
|
+
async before(request) {
|
|
1338
|
+
seenQuery = request.query
|
|
1339
|
+
return { ok: true }
|
|
1340
|
+
},
|
|
1341
|
+
},
|
|
1342
|
+
],
|
|
1343
|
+
},
|
|
1344
|
+
])
|
|
1345
|
+
|
|
1346
|
+
const res = await route.DELETE(new Request(`http://x/api/example/todos?id=${created.id}&reason=cleanup`, { method: 'DELETE' }))
|
|
1347
|
+
|
|
1348
|
+
expect(res.status).toBe(200)
|
|
1349
|
+
expect(seenQuery).toEqual({ id: created.id, reason: 'cleanup' })
|
|
1350
|
+
})
|
|
787
1351
|
})
|
|
788
1352
|
|
|
789
1353
|
describe('CRUD Factory — optimistic-lock auto-registration', () => {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { matchesEntity, runMutationGuards } from '../mutation-guard-registry'
|
|
2
2
|
import type { MutationGuard, MutationGuardInput } from '../mutation-guard-registry'
|
|
3
|
+
import {
|
|
4
|
+
applyAclFeatureOverrides,
|
|
5
|
+
resetModuleContractOverridesForTests,
|
|
6
|
+
} from '../../../modules/overrides'
|
|
3
7
|
|
|
4
8
|
describe('matchesEntity', () => {
|
|
5
9
|
it('matches wildcard "*" against any entity', () => {
|
|
@@ -24,6 +28,10 @@ describe('matchesEntity', () => {
|
|
|
24
28
|
})
|
|
25
29
|
|
|
26
30
|
describe('runMutationGuards', () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
resetModuleContractOverridesForTests()
|
|
33
|
+
})
|
|
34
|
+
|
|
27
35
|
const baseInput: MutationGuardInput = {
|
|
28
36
|
tenantId: 'tenant-1',
|
|
29
37
|
organizationId: 'org-1',
|
|
@@ -159,6 +167,24 @@ describe('runMutationGuards', () => {
|
|
|
159
167
|
expect(resultWithWildcard.errorBody).toEqual({ error: 'Blocked by wildcard', guardId: 'g1' })
|
|
160
168
|
})
|
|
161
169
|
|
|
170
|
+
it('does not run a guard gated by a nulled ACL feature', async () => {
|
|
171
|
+
applyAclFeatureOverrides({ 'premium.locks': null })
|
|
172
|
+
const guard = makeGuard({
|
|
173
|
+
id: 'g1',
|
|
174
|
+
features: ['premium.locks'],
|
|
175
|
+
validate: jest.fn().mockResolvedValue({ ok: false }),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const result = await runMutationGuards(
|
|
179
|
+
[guard],
|
|
180
|
+
baseInput,
|
|
181
|
+
{ userFeatures: ['*', 'premium.locks'] },
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
expect(result.ok).toBe(true)
|
|
185
|
+
expect(guard.validate).not.toHaveBeenCalled()
|
|
186
|
+
})
|
|
187
|
+
|
|
162
188
|
it('uses custom error body when provided', async () => {
|
|
163
189
|
const guard = makeGuard({
|
|
164
190
|
id: 'g1',
|