@open-mercato/shared 0.6.8-develop.7015.1.af90a2ddc7 → 0.6.8-develop.7019.1.f4c01c4b5c

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.
@@ -94,6 +94,244 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
94
94
  expect(emFlush).toHaveBeenCalledTimes(1)
95
95
  })
96
96
 
97
+ it('retains the creator identity for a regular tenant-scoped key', async () => {
98
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
99
+ findApiKeyBySecret.mockResolvedValue({
100
+ id: 'key-tenant-scoped',
101
+ name: 'tenant scoped',
102
+ tenantId: 'tenant-1',
103
+ organizationId: null,
104
+ rolesJson: [],
105
+ sessionToken: null,
106
+ sessionUserId: null,
107
+ sessionSecretEncrypted: null,
108
+ opencodeSessionId: null,
109
+ createdBy: 'creator-1',
110
+ expiresAt: null,
111
+ lastUsedAt: null,
112
+ })
113
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
114
+ if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
115
+ if (where.id === 'creator-1') {
116
+ return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'creator-org' }
117
+ }
118
+ return null
119
+ })
120
+
121
+ const auth = await getAuthFromRequest(buildRequest('tenant-scoped-secret'))
122
+
123
+ expect(auth).toMatchObject({
124
+ sub: 'api_key:key-tenant-scoped',
125
+ tenantId: 'tenant-1',
126
+ orgId: null,
127
+ isApiKey: true,
128
+ keyId: 'key-tenant-scoped',
129
+ userId: 'creator-1',
130
+ })
131
+ // The creator remains the key's legacy identity, but its concrete
132
+ // organization does not narrow a tenant-scoped key.
133
+ expect(emFindOne).toHaveBeenCalledWith(
134
+ expect.anything(),
135
+ { id: 'creator-1', deletedAt: null },
136
+ )
137
+ })
138
+
139
+ it('rejects a tenant-scoped regular key once its creator is soft-deleted', async () => {
140
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
141
+ findApiKeyBySecret.mockResolvedValue({
142
+ id: 'key-deleted-creator',
143
+ name: 'deleted creator',
144
+ tenantId: 'tenant-1',
145
+ organizationId: null,
146
+ rolesJson: [],
147
+ sessionToken: null,
148
+ sessionUserId: null,
149
+ sessionSecretEncrypted: null,
150
+ opencodeSessionId: null,
151
+ createdBy: 'creator-1',
152
+ expiresAt: null,
153
+ lastUsedAt: null,
154
+ })
155
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
156
+ if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
157
+ return null
158
+ })
159
+
160
+ await expect(
161
+ getAuthFromRequest(buildRequest('deleted-creator-secret')),
162
+ ).resolves.toBeNull()
163
+ })
164
+
165
+ it('rejects a tenant-scoped regular key whose creator moved to another tenant', async () => {
166
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
167
+ findApiKeyBySecret.mockResolvedValue({
168
+ id: 'key-foreign-creator',
169
+ name: 'foreign creator',
170
+ tenantId: 'tenant-1',
171
+ organizationId: null,
172
+ rolesJson: [],
173
+ sessionToken: null,
174
+ sessionUserId: null,
175
+ sessionSecretEncrypted: null,
176
+ opencodeSessionId: null,
177
+ createdBy: 'creator-1',
178
+ expiresAt: null,
179
+ lastUsedAt: null,
180
+ })
181
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
182
+ if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
183
+ if (where.id === 'creator-1') {
184
+ return { id: 'creator-1', tenantId: 'tenant-2', organizationId: null }
185
+ }
186
+ return null
187
+ })
188
+
189
+ await expect(
190
+ getAuthFromRequest(buildRequest('foreign-creator-secret')),
191
+ ).resolves.toBeNull()
192
+ })
193
+
194
+ it('accepts a tenant-scoped regular key that records no creator', async () => {
195
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
196
+ findApiKeyBySecret.mockResolvedValue({
197
+ id: 'key-no-creator',
198
+ name: 'no creator',
199
+ tenantId: 'tenant-1',
200
+ organizationId: null,
201
+ rolesJson: [],
202
+ sessionToken: null,
203
+ sessionUserId: null,
204
+ sessionSecretEncrypted: null,
205
+ opencodeSessionId: null,
206
+ createdBy: null,
207
+ expiresAt: null,
208
+ lastUsedAt: null,
209
+ })
210
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
211
+ if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
212
+ return null
213
+ })
214
+
215
+ await expect(
216
+ getAuthFromRequest(buildRequest('no-creator-secret')),
217
+ ).resolves.toMatchObject({ keyId: 'key-no-creator', tenantId: 'tenant-1' })
218
+ })
219
+
220
+ it('retains the creator identity for an organization-scoped regular key', async () => {
221
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
222
+ findApiKeyBySecret.mockResolvedValue({
223
+ id: 'key-organization-scoped',
224
+ name: 'organization scoped',
225
+ tenantId: 'tenant-1',
226
+ organizationId: 'org-1',
227
+ rolesJson: [],
228
+ sessionToken: null,
229
+ sessionUserId: null,
230
+ sessionSecretEncrypted: null,
231
+ opencodeSessionId: null,
232
+ createdBy: 'creator-1',
233
+ expiresAt: null,
234
+ lastUsedAt: null,
235
+ })
236
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
237
+ if (where.id === 'creator-1') {
238
+ return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'org-1' }
239
+ }
240
+ return null
241
+ })
242
+
243
+ await expect(
244
+ getAuthFromRequest(buildRequest('organization-scoped-secret')),
245
+ ).resolves.toMatchObject({
246
+ sub: 'api_key:key-organization-scoped',
247
+ tenantId: 'tenant-1',
248
+ orgId: 'org-1',
249
+ userId: 'creator-1',
250
+ })
251
+ })
252
+
253
+ it('rejects an organization-scoped regular key when its creator scope no longer matches', async () => {
254
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
255
+ findApiKeyBySecret.mockResolvedValue({
256
+ id: 'key-organization-mismatch',
257
+ name: 'organization mismatch',
258
+ tenantId: 'tenant-1',
259
+ organizationId: 'org-1',
260
+ rolesJson: [],
261
+ sessionToken: null,
262
+ sessionUserId: null,
263
+ sessionSecretEncrypted: null,
264
+ opencodeSessionId: null,
265
+ createdBy: 'creator-1',
266
+ expiresAt: null,
267
+ lastUsedAt: null,
268
+ })
269
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
270
+ if (where.id === 'creator-1') {
271
+ return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'org-2' }
272
+ }
273
+ return null
274
+ })
275
+
276
+ await expect(
277
+ getAuthFromRequest(buildRequest('organization-scope-mismatch-secret')),
278
+ ).resolves.toBeNull()
279
+ })
280
+
281
+ it('keeps session keys strictly bound to their persisted user and scope', async () => {
282
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
283
+ findApiKeyBySecret.mockResolvedValue({
284
+ id: 'key-session-scoped',
285
+ name: 'session scoped',
286
+ tenantId: 'tenant-1',
287
+ organizationId: null,
288
+ rolesJson: [],
289
+ sessionToken: 'sess_123',
290
+ sessionUserId: 'session-user-1',
291
+ sessionSecretEncrypted: null,
292
+ opencodeSessionId: null,
293
+ createdBy: 'session-user-1',
294
+ expiresAt: null,
295
+ lastUsedAt: null,
296
+ })
297
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
298
+ if (where.id === 'session-user-1') {
299
+ return { id: 'session-user-1', tenantId: 'tenant-1', organizationId: 'user-org' }
300
+ }
301
+ return null
302
+ })
303
+
304
+ await expect(
305
+ getAuthFromRequest(buildRequest('session-scope-mismatch-secret')),
306
+ ).resolves.toBeNull()
307
+ })
308
+
309
+ it('fails closed when a row has session markers but no bound session user', async () => {
310
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
311
+ findApiKeyBySecret.mockResolvedValue({
312
+ id: 'key-malformed-session',
313
+ name: 'malformed session',
314
+ tenantId: 'tenant-1',
315
+ organizationId: null,
316
+ rolesJson: [],
317
+ sessionToken: 'sess_missing_user',
318
+ sessionUserId: null,
319
+ sessionSecretEncrypted: null,
320
+ opencodeSessionId: null,
321
+ createdBy: 'creator-1',
322
+ expiresAt: null,
323
+ lastUsedAt: null,
324
+ })
325
+
326
+ await expect(
327
+ getAuthFromRequest(buildRequest('malformed-session-secret')),
328
+ ).resolves.toBeNull()
329
+ expect(emFindOne).not.toHaveBeenCalledWith(
330
+ expect.anything(),
331
+ expect.objectContaining({ id: 'creator-1' }),
332
+ )
333
+ })
334
+
97
335
  it('caches negative lookups so invalid keys skip the bcrypt+DB path', async () => {
98
336
  const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
99
337
  findApiKeyBySecret.mockResolvedValue(null)
@@ -128,4 +366,90 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
128
366
  await getAuthFromRequest(buildRequest('secret-invalidate'))
129
367
  expect(findApiKeyBySecret).toHaveBeenCalledTimes(2)
130
368
  })
369
+
370
+ describe('super-admin bit stays bounded by the effective key scope', () => {
371
+ async function resolveWithSuperAdminRole(input: {
372
+ secret: string
373
+ keyId: string
374
+ keyOrganizationId: string | null
375
+ aclOrganizations: string[] | null
376
+ }) {
377
+ const { RoleAcl } = await import('@open-mercato/core/modules/auth/data/entities')
378
+ const { Organization, Tenant } = await import('@open-mercato/core/modules/directory/data/entities')
379
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
380
+ // A creator-less key is validated against a live tenant (and organization when bound).
381
+ emFindOne.mockImplementation(async (entity: unknown) => {
382
+ if (entity === Tenant) return { id: 'tenant-1' }
383
+ if (entity === Organization) return { id: input.keyOrganizationId, tenant: { id: 'tenant-1' } }
384
+ return null
385
+ })
386
+ findApiKeyBySecret.mockResolvedValue({
387
+ id: input.keyId,
388
+ name: 'super admin key',
389
+ tenantId: 'tenant-1',
390
+ organizationId: input.keyOrganizationId,
391
+ rolesJson: ['role-super'],
392
+ sessionToken: null,
393
+ sessionUserId: null,
394
+ sessionSecretEncrypted: null,
395
+ opencodeSessionId: null,
396
+ createdBy: null,
397
+ expiresAt: null,
398
+ lastUsedAt: null,
399
+ })
400
+ emFind.mockImplementation(async (entity: unknown) => {
401
+ if (entity === RoleAcl) {
402
+ return [{ isSuperAdmin: true, organizationsJson: input.aclOrganizations }]
403
+ }
404
+ return []
405
+ })
406
+ return getAuthFromRequest(buildRequest(input.secret))
407
+ }
408
+
409
+ it('withholds it from an organization-bound key even when a role grants it', async () => {
410
+ // Generic guards trust this bit before live RBAC, so an organization-restricted key must
411
+ // never take super-admin shortcuts outside its own scope.
412
+ const auth = await resolveWithSuperAdminRole({
413
+ secret: 'super-bound',
414
+ keyId: 'key-super-bound',
415
+ keyOrganizationId: 'org-1',
416
+ aclOrganizations: null,
417
+ })
418
+
419
+ expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: false })
420
+ })
421
+
422
+ it('withholds it when the granting role ACL is itself organization-restricted', async () => {
423
+ const auth = await resolveWithSuperAdminRole({
424
+ secret: 'super-restricted',
425
+ keyId: 'key-super-restricted',
426
+ keyOrganizationId: null,
427
+ aclOrganizations: ['org-1'],
428
+ })
429
+
430
+ expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: false })
431
+ })
432
+
433
+ it('grants it for an unbound key with an unrestricted role grant', async () => {
434
+ const auth = await resolveWithSuperAdminRole({
435
+ secret: 'super-global',
436
+ keyId: 'key-super-global',
437
+ keyOrganizationId: null,
438
+ aclOrganizations: null,
439
+ })
440
+
441
+ expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: true })
442
+ })
443
+
444
+ it('grants it for an unbound key whose role ACL uses the __all__ sentinel', async () => {
445
+ const auth = await resolveWithSuperAdminRole({
446
+ secret: 'super-all-sentinel',
447
+ keyId: 'key-super-all-sentinel',
448
+ keyOrganizationId: null,
449
+ aclOrganizations: ['__all__'],
450
+ })
451
+
452
+ expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: true })
453
+ })
454
+ })
131
455
  })
@@ -0,0 +1,110 @@
1
+ import type { AuthContext } from './server'
2
+
3
+ export type PrincipalScope = {
4
+ tenantId: string
5
+ organizationId: string
6
+ }
7
+
8
+ export type AuthPrincipalType = 'user' | 'role'
9
+
10
+ export type AuthPrincipalLabel = {
11
+ id: string
12
+ label: string
13
+ secondary: string | null
14
+ }
15
+
16
+ export type AuthPrincipalRolePage = {
17
+ items: AuthPrincipalLabel[]
18
+ page: number
19
+ pageSize: number
20
+ total: number
21
+ }
22
+
23
+ /** Public, request-scoped read boundary owned by the Auth module. */
24
+ export interface AuthPrincipalService {
25
+ principalExists(input: {
26
+ type: AuthPrincipalType
27
+ id: string
28
+ scope: PrincipalScope
29
+ }): Promise<boolean>
30
+ resolveActiveUserRoleIds(userId: string, scope: PrincipalScope): Promise<string[]>
31
+ filterActiveRoleIds(roleIds: string[], scope: PrincipalScope): Promise<string[]>
32
+ resolveLabels(input: {
33
+ type: AuthPrincipalType
34
+ ids: string[]
35
+ scope: PrincipalScope
36
+ }): Promise<AuthPrincipalLabel[]>
37
+ /**
38
+ * Optional additive capability for organization-eligible role pickers.
39
+ * Implementations must apply eligibility before pagination and bound the
40
+ * advertised result window so sparse ACL matches cannot amplify requests.
41
+ */
42
+ queryActiveRolePage?(input: {
43
+ scope: PrincipalScope
44
+ search?: string
45
+ excludedIds?: string[]
46
+ page: number
47
+ pageSize: number
48
+ }): Promise<AuthPrincipalRolePage>
49
+ listSuperAdminUserIds(tenantId: string): Promise<string[]>
50
+ }
51
+
52
+ /** Public, request-scoped read boundary owned by the API Keys module. */
53
+ export interface ApiKeyPrincipalService {
54
+ resolveAssignedRoleIds(apiKeyId: string, scope: PrincipalScope): Promise<string[]>
55
+ }
56
+
57
+ export type OrganizationScope = {
58
+ selectedId: string | null
59
+ filterIds: string[] | null
60
+ allowedIds: string[] | null
61
+ tenantId: string | null
62
+ // True when an explicit organization selection could not be honored. Reads
63
+ // fall back to the caller's accessible organizations; writes must fail.
64
+ selectionRejected?: boolean
65
+ }
66
+
67
+ export type OrganizationScopeAcl = {
68
+ isSuperAdmin: boolean
69
+ features: string[]
70
+ organizations: string[] | null
71
+ }
72
+
73
+ export type OrganizationScopeRequest = Request | {
74
+ cookies?: { get: (name: string) => { value: string } | undefined }
75
+ headers?: { get(name: string): string | null }
76
+ }
77
+
78
+ /**
79
+ * Narrow, request-scoped hierarchy boundary owned by Directory.
80
+ *
81
+ * `null` means the selected organization does not exist in the requested
82
+ * tenant. An empty array means it exists but has no ancestors.
83
+ */
84
+ export interface OrganizationHierarchyService {
85
+ resolveAncestorIds(input: {
86
+ tenantId: string
87
+ organizationId: string
88
+ }): Promise<string[] | null>
89
+ }
90
+
91
+ /** Public, request-scoped organization expansion boundary owned by Directory. */
92
+ export interface OrganizationScopeService {
93
+ resolve(input: {
94
+ auth: AuthContext | null | undefined
95
+ selectedId?: string | null
96
+ tenantId?: string | null
97
+ freshAcl?: boolean
98
+ }): Promise<OrganizationScope>
99
+ resolveFresh(input: {
100
+ auth: NonNullable<AuthContext>
101
+ selectedId?: string | null
102
+ tenantId?: string | null
103
+ }): Promise<{ scope: OrganizationScope; acl: OrganizationScopeAcl }>
104
+ resolveForRequest(input: {
105
+ auth: AuthContext | null | undefined
106
+ request?: OrganizationScopeRequest
107
+ selectedId?: string | null
108
+ tenantId?: string | null
109
+ }): Promise<OrganizationScope>
110
+ }
@@ -195,13 +195,33 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
195
195
  : []
196
196
  const roleNames = roles.map((role) => role.name).filter((name): name is string => typeof name === 'string' && name.length > 0)
197
197
 
198
+ // A role-level super-admin grant is authorization-grade only when it is genuinely
199
+ // unrestricted: the grant itself must not be organization-scoped, it must belong to the
200
+ // key's tenant, and the key must not be bound to a single organization. RbacService applies
201
+ // the same intersection when projecting the scoped ACL, but generic guards
202
+ // (tenantAccess.resolveIsSuperAdmin, the scoped-API helpers) trust this raw bit *before*
203
+ // live RBAC runs — so an organization-restricted key must never carry it.
198
204
  let keyIsSuperAdmin = false
199
- if (roleIds.length) {
200
- const superAcl = await em.findOne(
205
+ const keyOrganizationId = typeof record.organizationId === 'string' && record.organizationId.trim().length > 0
206
+ ? record.organizationId.trim()
207
+ : null
208
+ if (roleIds.length && !keyOrganizationId) {
209
+ const superAcls = await em.find(
201
210
  RoleAcl,
202
- { role: { $in: roleIds } as any, isSuperAdmin: true, deletedAt: null } as any,
211
+ {
212
+ role: { $in: roleIds } as any,
213
+ tenantId: record.tenantId ?? null,
214
+ isSuperAdmin: true,
215
+ deletedAt: null,
216
+ } as any,
203
217
  )
204
- keyIsSuperAdmin = !!(superAcl && (superAcl as { isSuperAdmin?: boolean }).isSuperAdmin)
218
+ keyIsSuperAdmin = superAcls.some((acl) => {
219
+ const organizations = Array.isArray((acl as { organizationsJson?: string[] | null }).organizationsJson)
220
+ ? (acl as { organizationsJson?: string[] | null }).organizationsJson as string[]
221
+ : null
222
+ // An empty list means "inherit the key's own binding"; with no binding that is tenant-wide.
223
+ return !organizations || organizations.length === 0 || organizations.includes('__all__')
224
+ })
205
225
  }
206
226
 
207
227
  if (cache.shouldWriteLastUsed(record.id)) {
@@ -213,8 +233,25 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
213
233
  }
214
234
  }
215
235
 
216
- // For session keys, use sessionUserId; for regular keys, use createdBy
217
- const actualUserId = record.sessionUserId ?? record.createdBy ?? null
236
+ // Ephemeral session keys are always user-bound. Regular keys retain their
237
+ // legacy creator identity, while tenant-scoped regular keys ignore only the
238
+ // creator's concrete organization when validating the wider key scope.
239
+ // Keep every session marker fail-closed so a malformed session key cannot
240
+ // fall back to the regular key path and escape its user/scope binding.
241
+ const isSessionBoundKey = Boolean(
242
+ record.sessionToken
243
+ || record.sessionUserId
244
+ || record.sessionSecretEncrypted
245
+ || record.opencodeSessionId
246
+ )
247
+ const actualUserId = isSessionBoundKey
248
+ ? record.sessionUserId ?? null
249
+ : record.createdBy ?? null
250
+
251
+ if (isSessionBoundKey && !actualUserId) {
252
+ cache.setMiss(secret)
253
+ return null
254
+ }
218
255
 
219
256
  if (actualUserId) {
220
257
  const user = await em.findOne(User, { id: actualUserId, deletedAt: null })
@@ -226,7 +263,8 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
226
263
  cache.setMiss(secret)
227
264
  return null
228
265
  }
229
- if ((user.organizationId ?? null) !== (record.organizationId ?? null)) {
266
+ const requiresExactOrganization = isSessionBoundKey || Boolean(record.organizationId)
267
+ if (requiresExactOrganization && (user.organizationId ?? null) !== (record.organizationId ?? null)) {
230
268
  cache.setMiss(secret)
231
269
  return null
232
270
  }
@@ -68,7 +68,15 @@ describe('DataEngine event contract validation (issue #1421)', () => {
68
68
  })
69
69
 
70
70
  expect(emitted).toEqual([
71
- expect.objectContaining({ name: 'issue1421_test.widget.created' }),
71
+ expect.objectContaining({
72
+ name: 'issue1421_test.widget.created',
73
+ options: {
74
+ persistent: false,
75
+ tenantId: 'tenant-1',
76
+ organizationId: 'org-1',
77
+ emitterModuleId: 'issue1421_test',
78
+ },
79
+ }),
72
80
  ])
73
81
  expect(loggerWarn).not.toHaveBeenCalled()
74
82
  } finally {
@@ -226,7 +226,12 @@ export class DefaultDataEngine implements DataEngine {
226
226
  const eventName = `${mod}.${ent}.updated`
227
227
  warnIfUndeclaredEvent(eventName, 'setCustomFields')
228
228
  try {
229
- await bus.emitEvent(eventName, { id: recordId, organizationId, tenantId }, { persistent: true })
229
+ await bus.emitEvent(eventName, { id: recordId, organizationId, tenantId }, {
230
+ persistent: true,
231
+ tenantId,
232
+ organizationId,
233
+ emitterModuleId: mod,
234
+ })
230
235
  } catch {
231
236
  // non-blocking
232
237
  }
@@ -627,6 +632,7 @@ export class DefaultDataEngine implements DataEngine {
627
632
  persistent: !!events.persistent,
628
633
  tenantId: ctx.identifiers.tenantId ?? null,
629
634
  organizationId: ctx.identifiers.organizationId ?? null,
635
+ emitterModuleId: events.module,
630
636
  })
631
637
  } catch {
632
638
  // non-blocking
@@ -0,0 +1,88 @@
1
+ const GLOBAL_EVENT_REGISTRY_KEY = '__openMercatoEventDefinitionRegistry__'
2
+
3
+ type EventFactoryModule = typeof import('../factory')
4
+
5
+ describe('event definition registry', () => {
6
+ const globalScope = globalThis as Record<string, unknown>
7
+ const originalRegistry = globalScope[GLOBAL_EVENT_REGISTRY_KEY]
8
+
9
+ beforeEach(() => {
10
+ delete globalScope[GLOBAL_EVENT_REGISTRY_KEY]
11
+ })
12
+
13
+ afterAll(() => {
14
+ if (originalRegistry === undefined) {
15
+ delete globalScope[GLOBAL_EVENT_REGISTRY_KEY]
16
+ } else {
17
+ globalScope[GLOBAL_EVENT_REGISTRY_KEY] = originalRegistry
18
+ }
19
+ })
20
+
21
+ it('shares declarations and registered configs across isolated module instances', () => {
22
+ let firstInstance: EventFactoryModule | undefined
23
+ let secondInstance: EventFactoryModule | undefined
24
+
25
+ jest.isolateModules(() => {
26
+ firstInstance = require('../factory') as EventFactoryModule
27
+ const config = firstInstance.createModuleEvents({
28
+ moduleId: 'isolated_events_test',
29
+ events: [{
30
+ id: 'isolated_events_test.invalidated',
31
+ label: 'Invalidated',
32
+ crossProcessBroadcast: true,
33
+ }] as const,
34
+ })
35
+ firstInstance.registerEventModuleConfigs([config])
36
+ })
37
+
38
+ jest.isolateModules(() => {
39
+ secondInstance = require('../factory') as EventFactoryModule
40
+ })
41
+
42
+ expect(secondInstance).not.toBe(firstInstance)
43
+ expect(secondInstance?.isEventDeclared('isolated_events_test.invalidated')).toBe(true)
44
+ expect(secondInstance?.isCrossProcessBroadcastEvent('isolated_events_test.invalidated')).toBe(true)
45
+ expect(secondInstance?.getDeclaredEvents()).toEqual(expect.arrayContaining([
46
+ expect.objectContaining({
47
+ id: 'isolated_events_test.invalidated',
48
+ module: 'isolated_events_test',
49
+ }),
50
+ ]))
51
+ expect(secondInstance?.getEventModuleConfigs()).toHaveLength(1)
52
+ })
53
+
54
+ it('refreshes a module definition during HMR without duplicating its event id', () => {
55
+ let firstInstance: EventFactoryModule | undefined
56
+ let secondInstance: EventFactoryModule | undefined
57
+
58
+ jest.isolateModules(() => {
59
+ firstInstance = require('../factory') as EventFactoryModule
60
+ firstInstance.createModuleEvents({
61
+ moduleId: 'hmr_events_test',
62
+ events: [{ id: 'hmr_events_test.changed', label: 'Before' }] as const,
63
+ })
64
+ })
65
+
66
+ jest.isolateModules(() => {
67
+ secondInstance = require('../factory') as EventFactoryModule
68
+ secondInstance.createModuleEvents({
69
+ moduleId: 'hmr_events_test',
70
+ events: [{
71
+ id: 'hmr_events_test.changed',
72
+ label: 'After',
73
+ clientBroadcast: true,
74
+ }] as const,
75
+ })
76
+ })
77
+
78
+ expect(firstInstance?.isBroadcastEvent('hmr_events_test.changed')).toBe(true)
79
+ expect(firstInstance?.getAllDeclaredEventIds()).toEqual(['hmr_events_test.changed'])
80
+ expect(firstInstance?.getDeclaredEvents()).toEqual([
81
+ expect.objectContaining({
82
+ id: 'hmr_events_test.changed',
83
+ label: 'After',
84
+ clientBroadcast: true,
85
+ }),
86
+ ])
87
+ })
88
+ })