@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.
Files changed (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/lib/auth/jwt.js +4 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationScope.js +15 -1
  6. package/dist/lib/auth/organizationScope.js.map +2 -2
  7. package/dist/lib/availability/catalogOnlyProvider.js +77 -0
  8. package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
  9. package/dist/lib/availability/index.js +8 -0
  10. package/dist/lib/availability/index.js.map +7 -0
  11. package/dist/lib/availability/registry.js +47 -0
  12. package/dist/lib/availability/registry.js.map +7 -0
  13. package/dist/lib/availability/types.js +7 -0
  14. package/dist/lib/availability/types.js.map +7 -0
  15. package/dist/lib/catalog-visibility/index.js +11 -0
  16. package/dist/lib/catalog-visibility/index.js.map +7 -0
  17. package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
  18. package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
  19. package/dist/lib/catalog-visibility/matchesOne.js +27 -0
  20. package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
  21. package/dist/lib/catalog-visibility/matchesScope.js +9 -0
  22. package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
  23. package/dist/lib/catalog-visibility/types.js +1 -0
  24. package/dist/lib/catalog-visibility/types.js.map +7 -0
  25. package/dist/lib/catalog-visibility/unionScopes.js +9 -0
  26. package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
  27. package/dist/lib/commands/command-bus.js +383 -125
  28. package/dist/lib/commands/command-bus.js.map +3 -3
  29. package/dist/lib/commands/flush.js +16 -0
  30. package/dist/lib/commands/flush.js.map +2 -2
  31. package/dist/lib/commands/index.js +8 -0
  32. package/dist/lib/commands/index.js.map +2 -2
  33. package/dist/lib/commands/redo.js +2 -7
  34. package/dist/lib/commands/redo.js.map +2 -2
  35. package/dist/lib/commands/transaction-lifetime.js +67 -0
  36. package/dist/lib/commands/transaction-lifetime.js.map +7 -0
  37. package/dist/lib/commands/types.js.map +2 -2
  38. package/dist/lib/commands/undo.js +54 -2
  39. package/dist/lib/commands/undo.js.map +2 -2
  40. package/dist/lib/crud/enricher-runner.js +64 -9
  41. package/dist/lib/crud/enricher-runner.js.map +2 -2
  42. package/dist/lib/crud/factory.js +42 -18
  43. package/dist/lib/crud/factory.js.map +2 -2
  44. package/dist/lib/data/engine.js +12 -3
  45. package/dist/lib/data/engine.js.map +2 -2
  46. package/dist/lib/db/accentInsensitiveSearch.js +21 -0
  47. package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
  48. package/dist/lib/encryption/kms.js +1 -5
  49. package/dist/lib/encryption/kms.js.map +2 -2
  50. package/dist/lib/encryption/likeFilterWarning.js +3 -1
  51. package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
  52. package/dist/lib/encryption/migration-backfill.js +69 -0
  53. package/dist/lib/encryption/migration-backfill.js.map +7 -0
  54. package/dist/lib/encryption/policyMemo.js +28 -0
  55. package/dist/lib/encryption/policyMemo.js.map +7 -0
  56. package/dist/lib/encryption/subscriber.js +30 -3
  57. package/dist/lib/encryption/subscriber.js.map +2 -2
  58. package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
  59. package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
  60. package/dist/lib/modules/api-route-identity.js +102 -0
  61. package/dist/lib/modules/api-route-identity.js.map +7 -0
  62. package/dist/lib/telemetry/runtime.js.map +2 -2
  63. package/dist/lib/version.js +1 -1
  64. package/dist/lib/version.js.map +1 -1
  65. package/dist/modules/events/factory.js +20 -0
  66. package/dist/modules/events/factory.js.map +2 -2
  67. package/dist/modules/registry.js +3 -1
  68. package/dist/modules/registry.js.map +2 -2
  69. package/package.json +12 -5
  70. package/src/lib/auth/__tests__/jwt.test.ts +12 -0
  71. package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
  72. package/src/lib/auth/jwt.ts +9 -0
  73. package/src/lib/auth/organizationScope.ts +39 -0
  74. package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
  75. package/src/lib/availability/__tests__/registry.test.ts +130 -0
  76. package/src/lib/availability/catalogOnlyProvider.ts +152 -0
  77. package/src/lib/availability/index.ts +18 -0
  78. package/src/lib/availability/registry.ts +131 -0
  79. package/src/lib/availability/types.ts +86 -0
  80. package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
  81. package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
  82. package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
  83. package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
  84. package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
  85. package/src/lib/catalog-visibility/index.ts +5 -0
  86. package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
  87. package/src/lib/catalog-visibility/matchesOne.ts +28 -0
  88. package/src/lib/catalog-visibility/matchesScope.ts +8 -0
  89. package/src/lib/catalog-visibility/types.ts +31 -0
  90. package/src/lib/catalog-visibility/unionScopes.ts +16 -0
  91. package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
  92. package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
  93. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
  94. package/src/lib/commands/__tests__/redo.test.ts +4 -0
  95. package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
  96. package/src/lib/commands/__tests__/undo.test.ts +90 -1
  97. package/src/lib/commands/command-bus.ts +463 -148
  98. package/src/lib/commands/flush.ts +16 -0
  99. package/src/lib/commands/index.ts +7 -0
  100. package/src/lib/commands/redo.ts +4 -8
  101. package/src/lib/commands/transaction-lifetime.ts +88 -0
  102. package/src/lib/commands/types.ts +91 -0
  103. package/src/lib/commands/undo.ts +79 -1
  104. package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
  105. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
  106. package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
  107. package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
  108. package/src/lib/crud/enricher-runner.ts +83 -9
  109. package/src/lib/crud/factory.ts +46 -19
  110. package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
  111. package/src/lib/data/engine.ts +12 -0
  112. package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
  113. package/src/lib/db/accentInsensitiveSearch.ts +70 -0
  114. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
  115. package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
  116. package/src/lib/encryption/__tests__/kms.test.ts +45 -3
  117. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
  118. package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
  119. package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
  120. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
  121. package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
  122. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
  123. package/src/lib/encryption/kms.ts +1 -6
  124. package/src/lib/encryption/likeFilterWarning.ts +10 -2
  125. package/src/lib/encryption/migration-backfill.ts +90 -0
  126. package/src/lib/encryption/policyMemo.ts +51 -0
  127. package/src/lib/encryption/subscriber.ts +41 -4
  128. package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
  129. package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
  130. package/src/lib/modules/api-route-identity.ts +116 -0
  131. package/src/lib/telemetry/runtime.ts +8 -0
  132. package/src/modules/__tests__/registry.test.ts +92 -0
  133. package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
  134. package/src/modules/events/factory.ts +31 -0
  135. package/src/modules/events/types.ts +13 -0
  136. package/src/modules/notifications/types.ts +1 -0
  137. package/src/modules/registry.ts +4 -1
@@ -1,6 +1,19 @@
1
1
  import crypto from 'node:crypto'
2
2
  import { buildDerivedKeyFallbackBannerLines, createKmsService, HashicorpVaultKmsService } from '../kms'
3
3
 
4
+ jest.mock('../../logger', () => {
5
+ const warn = jest.fn()
6
+ const logger: Record<string, unknown> = {
7
+ debug: jest.fn(),
8
+ info: jest.fn(),
9
+ warn,
10
+ error: jest.fn(),
11
+ }
12
+ logger.child = () => logger
13
+ return { createLogger: () => logger, __warn: warn }
14
+ })
15
+
16
+ const loggerModule = jest.requireMock('../../logger') as { __warn: jest.Mock }
4
17
  const originalEnv = { ...process.env }
5
18
 
6
19
  describe('kms timeout handling', () => {
@@ -74,8 +87,9 @@ describe('kms timeout handling', () => {
74
87
  expect(dek).toBeNull()
75
88
  })
76
89
 
77
- it('never prints the explicit fallback secret verbatim in the banner, regardless of NODE_ENV', () => {
90
+ it('never prints the explicit fallback secret or a deterministic verifier in the banner', () => {
78
91
  const secret = 'super-secret-tenant-encryption-key'
92
+ const fingerprint = crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)
79
93
  for (const nodeEnv of ['development', 'staging', 'preview', 'PRODUCTION', 'production', undefined]) {
80
94
  if (nodeEnv === undefined) delete process.env.NODE_ENV
81
95
  else process.env.NODE_ENV = nodeEnv
@@ -88,12 +102,40 @@ describe('kms timeout handling', () => {
88
102
  const rendered = lines.join('\n')
89
103
 
90
104
  expect(rendered).not.toContain(secret)
105
+ expect(rendered).not.toContain(fingerprint)
106
+ expect(rendered).not.toMatch(/fingerprint|sha-?256/i)
91
107
  expect(rendered).toContain('Source: TENANT_DATA_ENCRYPTION_FALLBACK_KEY')
92
- const expectedFingerprint = crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)
93
- expect(rendered).toContain(`Secret fingerprint (sha256, truncated): ${expectedFingerprint}`)
94
108
  }
95
109
  })
96
110
 
111
+ it('logs fallback activation without the secret or a deterministic verifier', () => {
112
+ const secret = 'structured-log-fallback-secret'
113
+ const fingerprint = crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)
114
+ process.env.NODE_ENV = 'production'
115
+ process.env.TENANT_DATA_ENCRYPTION = 'yes'
116
+ process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY = secret
117
+ delete process.env.TENANT_DATA_ENCRYPTION_KEY
118
+ delete process.env.VAULT_ADDR
119
+ delete process.env.VAULT_TOKEN
120
+ loggerModule.__warn.mockClear()
121
+ const stderrWrite = jest.spyOn(process.stderr, 'write').mockImplementation(() => true)
122
+
123
+ createKmsService()
124
+
125
+ const bannerOutput = stderrWrite.mock.calls.map(([chunk]) => String(chunk)).join('')
126
+ const structuredOutput = JSON.stringify(loggerModule.__warn.mock.calls)
127
+ for (const output of [bannerOutput, structuredOutput]) {
128
+ expect(output).not.toContain(secret)
129
+ expect(output).not.toContain(fingerprint)
130
+ expect(output).not.toMatch(/secretFingerprint|sha-?256/i)
131
+ }
132
+ expect(bannerOutput).toContain('Source: TENANT_DATA_ENCRYPTION_FALLBACK_KEY')
133
+ expect(loggerModule.__warn).toHaveBeenCalledWith(
134
+ 'Using derived tenant encryption keys (Vault unavailable / no DEK)',
135
+ { fallbackSource: 'TENANT_DATA_ENCRYPTION_FALLBACK_KEY' },
136
+ )
137
+ })
138
+
97
139
  it('does not echo the dev default secret verbatim in the banner either', () => {
98
140
  const lines = buildDerivedKeyFallbackBannerLines({
99
141
  secret: 'om-dev-tenant-encryption',
@@ -99,6 +99,49 @@ describe('warnOnEncryptedLikeFilter', () => {
99
99
  expect(loggerModule.__warn.mock.calls[0][1].hint).toContain('findEntityIdsBySearchTokens')
100
100
  })
101
101
 
102
+ // A predicate built with raw() is keyed by a symbol, which the walker cannot
103
+ // see, so the caller names the matched properties instead (#6074 / #5051).
104
+ it('warns for properties supplied as likeFields without a readable where clause', async () => {
105
+ await warnOnEncryptedLikeFilter({
106
+ em: makeEm() as any,
107
+ entityName: 'MailMessage',
108
+ likeFields: ['subject', 'body'],
109
+ tenantId: 'tenant-1',
110
+ encryptionService: makeService(['subject']) as any,
111
+ })
112
+
113
+ expect(loggerModule.__warn).toHaveBeenCalledTimes(1)
114
+ expect(loggerModule.__warn.mock.calls[0][1]).toMatchObject({
115
+ entity: 'mail:mail_message',
116
+ field: 'subject',
117
+ reason: 'raw-orm-filter',
118
+ })
119
+ })
120
+
121
+ it('reports a property once when it appears in both the where clause and likeFields', async () => {
122
+ await warnOnEncryptedLikeFilter({
123
+ em: makeEm() as any,
124
+ entityName: 'MailMessage',
125
+ where: { subject: { $ilike: '%invoice%' } },
126
+ likeFields: ['subject'],
127
+ tenantId: 'tenant-1',
128
+ encryptionService: makeService(['subject']) as any,
129
+ })
130
+
131
+ expect(loggerModule.__warn).toHaveBeenCalledTimes(1)
132
+ })
133
+
134
+ it('stays quiet for likeFields outside the encryption map', async () => {
135
+ await warnOnEncryptedLikeFilter({
136
+ em: makeEm() as any,
137
+ entityName: 'MailMessage',
138
+ likeFields: ['subject'],
139
+ tenantId: 'tenant-1',
140
+ encryptionService: makeService(['body']) as any,
141
+ })
142
+ expect(loggerModule.__warn).not.toHaveBeenCalled()
143
+ })
144
+
102
145
  it('stays quiet for properties outside the encryption map', async () => {
103
146
  await warnOnEncryptedLikeFilter({
104
147
  em: makeEm() as any,
@@ -0,0 +1,57 @@
1
+ import { buildEncryptionMapBackfillSql } from '../migration-backfill'
2
+
3
+ function normalize(sql: string): string {
4
+ return sql.replace(/\s+/g, ' ').trim()
5
+ }
6
+
7
+ describe('buildEncryptionMapBackfillSql', () => {
8
+ test('emits a single table-guarded block that inserts missing maps for active scopes', () => {
9
+ const sql = normalize(buildEncryptionMapBackfillSql({ entityId: 'demo:thing', fields: [{ field: 'secret' }] }))
10
+
11
+ expect(sql.startsWith('do $$ begin if to_regclass(\'encryption_maps\') is null then return; end if;')).toBe(true)
12
+ expect(sql.endsWith('end $$;')).toBe(true)
13
+ expect(sql).toContain(
14
+ `select gen_random_uuid(), 'demo:thing', src."tenant_id", src."organization_id", '[{"field":"secret"}]'::jsonb, true, now(), now()`,
15
+ )
16
+ expect(sql).toContain('where "is_active" = true and "deleted_at" is null')
17
+ expect(sql).toContain(`where existing."entity_id" = 'demo:thing'`)
18
+ })
19
+
20
+ test('appends each missing field rule to existing maps without replacing other rules', () => {
21
+ const sql = normalize(
22
+ buildEncryptionMapBackfillSql({
23
+ entityId: 'demo:thing',
24
+ fields: [{ field: 'secret' }, { field: 'email', hashField: 'email_hash' }],
25
+ }),
26
+ )
27
+
28
+ expect(sql).toContain(`'[{"field":"secret"},{"field":"email","hashField":"email_hash"}]'::jsonb`)
29
+ expect(sql).toContain(`coalesce("fields_json", '[]'::jsonb) || '[{"field":"secret"}]'::jsonb`)
30
+ expect(sql).toContain(`coalesce("fields_json", '[]'::jsonb) || '[{"field":"email","hashField":"email_hash"}]'::jsonb`)
31
+ expect(sql).toContain(`where rule->>'field' = 'secret'`)
32
+ expect(sql).toContain(`where rule->>'field' = 'email'`)
33
+ })
34
+
35
+ test('deduplicates repeated field rules', () => {
36
+ const sql = buildEncryptionMapBackfillSql({ entityId: 'demo:thing', fields: [{ field: 'secret' }, { field: 'secret' }] })
37
+
38
+ expect(sql.match(/update "encryption_maps"/g)).toHaveLength(1)
39
+ expect(sql).toContain(`'[{"field":"secret"}]'::jsonb, true`)
40
+ })
41
+
42
+ test.each([
43
+ ['entity id with a quote', { entityId: "demo:thing'; drop table x; --", fields: [{ field: 'secret' }] }],
44
+ ['entity id without module prefix', { entityId: 'thing', fields: [{ field: 'secret' }] }],
45
+ ['field with a quote', { entityId: 'demo:thing', fields: [{ field: "secret'" }] }],
46
+ ['hash field with a quote', { entityId: 'demo:thing', fields: [{ field: 'email', hashField: "x'" }] }],
47
+ ['no fields', { entityId: 'demo:thing', fields: [] }],
48
+ ])('rejects %s', (_label, map) => {
49
+ expect(() => buildEncryptionMapBackfillSql(map)).toThrow('[internal]')
50
+ })
51
+
52
+ test('rejects system-scoped maps, which never live in encryption_maps', () => {
53
+ expect(() =>
54
+ buildEncryptionMapBackfillSql({ entityId: 'demo:thing', keyScope: 'system', fields: [{ field: 'secret' }] }),
55
+ ).toThrow('system-scoped')
56
+ })
57
+ })
@@ -0,0 +1,128 @@
1
+ import { fork, type ChildProcess } from 'node:child_process'
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ type WorkerResponse = {
7
+ id?: number
8
+ ready?: boolean
9
+ exactReadBlocked?: boolean
10
+ ok?: boolean
11
+ value?: unknown
12
+ error?: string
13
+ }
14
+
15
+ function waitForMessage(worker: ChildProcess, predicate: (message: WorkerResponse) => boolean): Promise<WorkerResponse> {
16
+ return new Promise((resolve, reject) => {
17
+ const onMessage = (message: WorkerResponse) => {
18
+ if (!predicate(message)) return
19
+ cleanup()
20
+ resolve(message)
21
+ }
22
+ const onExit = (code: number | null) => {
23
+ cleanup()
24
+ reject(new Error(`[internal] Policy worker exited with code ${String(code)}`))
25
+ }
26
+ const cleanup = () => {
27
+ worker.off('message', onMessage)
28
+ worker.off('exit', onExit)
29
+ }
30
+ worker.on('message', onMessage)
31
+ worker.on('exit', onExit)
32
+ })
33
+ }
34
+
35
+ async function startWorker(
36
+ statePath: string,
37
+ cacheFailurePath: string,
38
+ exactReadBlockPath: string,
39
+ ): Promise<ChildProcess> {
40
+ const worker = fork(join(__dirname, 'fixtures/policy-process-worker.ts'), [], {
41
+ execArgv: ['--import', require.resolve('tsx')],
42
+ env: {
43
+ ...process.env,
44
+ TENANT_DATA_ENCRYPTION: 'yes',
45
+ ENCRYPTION_POLICY_STATE_PATH: statePath,
46
+ ENCRYPTION_POLICY_CACHE_FAILURE_PATH: cacheFailurePath,
47
+ ENCRYPTION_POLICY_EXACT_BLOCK_PATH: exactReadBlockPath,
48
+ },
49
+ stdio: ['ignore', 'ignore', 'inherit', 'ipc'],
50
+ })
51
+ await waitForMessage(worker, (message) => message.ready === true)
52
+ return worker
53
+ }
54
+
55
+ let commandId = 0
56
+
57
+ async function runCommand(worker: ChildProcess, action: 'readExact' | 'readAggregate' | 'invalidate') {
58
+ const id = ++commandId
59
+ const response = waitForMessage(worker, (message) => message.id === id)
60
+ worker.send({ id, action })
61
+ return response
62
+ }
63
+
64
+ describe('encryption policy coherence across processes', () => {
65
+ it('re-reads exact and aggregate policy after a remote commit while the shared cache is unavailable', async () => {
66
+ const directory = await mkdtemp(join(tmpdir(), 'om-encryption-policy-'))
67
+ const statePath = join(directory, 'policy.json')
68
+ const cacheFailurePath = join(directory, 'cache-status.txt')
69
+ const exactReadBlockPath = join(directory, 'exact-read-block.txt')
70
+ const workers: ChildProcess[] = []
71
+ try {
72
+ await writeFile(statePath, JSON.stringify({
73
+ exact: [{ field: 'stale_exact' }],
74
+ aggregate: [{ field: 'stale_aggregate' }],
75
+ }))
76
+ await writeFile(cacheFailurePath, 'available')
77
+ await writeFile(exactReadBlockPath, 'released')
78
+ const reader = await startWorker(statePath, cacheFailurePath, exactReadBlockPath)
79
+ const invalidator = await startWorker(statePath, cacheFailurePath, exactReadBlockPath)
80
+ workers.push(reader, invalidator)
81
+
82
+ await expect(runCommand(reader, 'readExact')).resolves.toMatchObject({
83
+ ok: true,
84
+ value: ['stale_exact'],
85
+ })
86
+ await expect(runCommand(reader, 'readAggregate')).resolves.toMatchObject({
87
+ ok: true,
88
+ value: ['stale_aggregate'],
89
+ })
90
+
91
+ await writeFile(exactReadBlockPath, 'blocked')
92
+ const exactReadBlocked = waitForMessage(reader, (message) => message.exactReadBlocked === true)
93
+ const blockedPreCommitRead = runCommand(reader, 'readExact')
94
+ await exactReadBlocked
95
+
96
+ await writeFile(statePath, JSON.stringify({
97
+ exact: [{ field: 'fresh_exact' }],
98
+ aggregate: [{ field: 'fresh_aggregate' }],
99
+ }))
100
+ await writeFile(cacheFailurePath, 'unavailable')
101
+ await expect(runCommand(invalidator, 'invalidate')).resolves.toMatchObject({ ok: true })
102
+
103
+ await expect(runCommand(reader, 'readExact')).resolves.toMatchObject({
104
+ ok: true,
105
+ value: ['fresh_exact'],
106
+ })
107
+ await expect(runCommand(reader, 'readAggregate')).resolves.toMatchObject({
108
+ ok: true,
109
+ value: ['fresh_aggregate'],
110
+ })
111
+ await writeFile(exactReadBlockPath, 'released')
112
+ await expect(blockedPreCommitRead).resolves.toMatchObject({
113
+ ok: true,
114
+ value: ['stale_exact'],
115
+ })
116
+
117
+ await writeFile(cacheFailurePath, 'available')
118
+ await expect(runCommand(invalidator, 'invalidate')).resolves.toMatchObject({ ok: true })
119
+ await expect(runCommand(reader, 'readExact')).resolves.toMatchObject({
120
+ ok: true,
121
+ value: ['fresh_exact'],
122
+ })
123
+ } finally {
124
+ for (const worker of workers) worker.kill()
125
+ await rm(directory, { recursive: true, force: true })
126
+ }
127
+ })
128
+ })
@@ -1,4 +1,4 @@
1
- import { ReferenceKind } from '@mikro-orm/core'
1
+ import { DriverException, ReferenceKind } from '@mikro-orm/core'
2
2
  import { TenantEncryptionSubscriber } from '../subscriber'
3
3
  import { registerEntityIds } from '../entityIds'
4
4
  import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
@@ -121,3 +121,68 @@ describe('TenantEncryptionSubscriber deep-decrypt of loaded collection relations
121
121
  expect(decryptedEntityIds).toContain('test:child')
122
122
  })
123
123
  })
124
+
125
+ describe('TenantEncryptionSubscriber nested decrypt failures inside a transaction', () => {
126
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
127
+
128
+ beforeEach(() => {
129
+ delete process.env.TENANT_DATA_ENCRYPTION
130
+ registerEntityIds({ test: { parent: 'test:parent', child: 'test:child' } })
131
+ })
132
+
133
+ afterEach(() => {
134
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
135
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
136
+ })
137
+
138
+ function makeFailingChildService(error: Error): TenantDataEncryptionService {
139
+ return {
140
+ isEnabled: () => true,
141
+ async decryptEntityPayload(entityId: string) {
142
+ if (entityId === 'test:child') throw error
143
+ return {}
144
+ },
145
+ } as unknown as TenantDataEncryptionService
146
+ }
147
+
148
+ function makeGraph() {
149
+ const child = { secret: 'enc:zeta', tenantId: 't1', __meta: CHILD_META }
150
+ const meta = parentMeta(ReferenceKind.MANY_TO_ONE, 'child')
151
+ return { meta, parent: { tenantId: 't1', child: makeReference(child), __meta: meta } }
152
+ }
153
+
154
+ function makeTransactionalEm(inTransaction: boolean) {
155
+ return { ...makeEm(), isInTransaction: () => inTransaction }
156
+ }
157
+
158
+ const abortedStatement = () => new DriverException(
159
+ Object.assign(new Error('current transaction is aborted'), { code: '25P02' }),
160
+ )
161
+
162
+ it('rethrows a database error so the caller sees its aborted transaction', async () => {
163
+ const { meta, parent } = makeGraph()
164
+ const subscriber = new TenantEncryptionSubscriber(makeFailingChildService(abortedStatement()))
165
+
166
+ await expect(
167
+ subscriber.decryptEntityGraph(parent, meta, makeTransactionalEm(true), { syncOriginal: true }),
168
+ ).rejects.toBeInstanceOf(DriverException)
169
+ })
170
+
171
+ it('keeps swallowing a database error outside a transaction', async () => {
172
+ const { meta, parent } = makeGraph()
173
+ const subscriber = new TenantEncryptionSubscriber(makeFailingChildService(abortedStatement()))
174
+
175
+ await expect(
176
+ subscriber.decryptEntityGraph(parent, meta, makeTransactionalEm(false), { syncOriginal: true }),
177
+ ).resolves.toBeUndefined()
178
+ })
179
+
180
+ it('keeps swallowing a non-database decrypt error inside a transaction', async () => {
181
+ const { meta, parent } = makeGraph()
182
+ const subscriber = new TenantEncryptionSubscriber(makeFailingChildService(new Error('malformed payload')))
183
+
184
+ await expect(
185
+ subscriber.decryptEntityGraph(parent, meta, makeTransactionalEm(true), { syncOriginal: true }),
186
+ ).resolves.toBeUndefined()
187
+ })
188
+ })
@@ -192,11 +192,14 @@ describe('tenant-less entity encryption delegation', () => {
192
192
  changeSet,
193
193
  } as never)
194
194
 
195
+ // The flushing EntityManager is handed through so the policy lookup runs on
196
+ // this transaction's connection instead of acquiring a second pooled one.
195
197
  expect(encryptEntityPayload).toHaveBeenCalledWith(
196
198
  'onboarding:onboarding_request',
197
199
  expect.any(Object),
198
200
  null,
199
201
  null,
202
+ { em: {} },
200
203
  )
201
204
  expect(entity.secret).toBe('encrypted')
202
205
  expect(changeSet.payload.secret).toBe('encrypted')