@open-mercato/events 0.8.1-develop.7296.1.2111d779db → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +18 -0
- package/dist/broadcast-coalescer.js +124 -0
- package/dist/broadcast-coalescer.js.map +7 -0
- package/dist/bus.js +108 -11
- package/dist/bus.js.map +2 -2
- package/dist/index.js +12 -0
- package/dist/index.js.map +2 -2
- package/dist/modules/events/api/route.js +1 -0
- package/dist/modules/events/api/route.js.map +2 -2
- package/dist/modules/events/api/stream/route.js +161 -20
- package/dist/modules/events/api/stream/route.js.map +2 -2
- package/package.json +4 -5
- package/src/__tests__/broadcast-coalescer.test.ts +172 -0
- package/src/__tests__/broadcast-coalescing-bus.test.ts +409 -0
- package/src/broadcast-coalescer.ts +204 -0
- package/src/bus.ts +199 -19
- package/src/index.ts +10 -0
- package/src/modules/events/api/__tests__/route.test.ts +27 -4
- package/src/modules/events/api/route.ts +1 -0
- package/src/modules/events/api/stream/__tests__/route.test.ts +650 -8
- package/src/modules/events/api/stream/route.ts +200 -26
|
@@ -1,11 +1,60 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
type MockAuth = {
|
|
2
|
+
tenantId: string | null
|
|
3
|
+
sub: string
|
|
4
|
+
orgId: string | null
|
|
5
|
+
roles: string[]
|
|
6
|
+
isApiKey?: boolean
|
|
7
|
+
isSuperAdmin?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type MockOrganizationScope = {
|
|
11
|
+
selectedId: string | null
|
|
12
|
+
filterIds: string[] | null
|
|
13
|
+
allowedIds: string[] | null
|
|
14
|
+
tenantId: string | null
|
|
15
|
+
selectionRejected?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type MockOrganizationScopeInput = {
|
|
19
|
+
auth: MockAuth | null | undefined
|
|
20
|
+
request?: Request
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const mockResolveOrganizationScopeForRequest = jest.fn(
|
|
24
|
+
async ({ auth }: MockOrganizationScopeInput): Promise<MockOrganizationScope> => {
|
|
25
|
+
const selectedId = auth?.orgId ?? null
|
|
26
|
+
return {
|
|
27
|
+
selectedId,
|
|
28
|
+
filterIds: selectedId ? [selectedId] : null,
|
|
29
|
+
allowedIds: selectedId ? [selectedId] : null,
|
|
30
|
+
tenantId: auth?.tenantId ?? null,
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
function buildResolvedContext(auth: MockAuth | null = {
|
|
36
|
+
tenantId: 't1',
|
|
37
|
+
sub: 'u1',
|
|
38
|
+
orgId: 'o1',
|
|
39
|
+
roles: ['admin'],
|
|
40
|
+
}) {
|
|
41
|
+
return {
|
|
3
42
|
ctx: {
|
|
4
|
-
auth
|
|
5
|
-
|
|
43
|
+
auth,
|
|
44
|
+
container: {
|
|
45
|
+
resolve(name: string) {
|
|
46
|
+
if (name !== 'organizationScopeService') throw new Error(`Unexpected service: ${name}`)
|
|
47
|
+
return { resolveForRequest: mockResolveOrganizationScopeForRequest }
|
|
48
|
+
},
|
|
49
|
+
},
|
|
6
50
|
},
|
|
7
|
-
|
|
8
|
-
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const mockResolveRequestContext = jest.fn(async () => buildResolvedContext())
|
|
55
|
+
|
|
56
|
+
jest.mock('@open-mercato/shared/lib/api/context', () => ({
|
|
57
|
+
resolveRequestContext: (...args: unknown[]) => mockResolveRequestContext(...args),
|
|
9
58
|
}))
|
|
10
59
|
|
|
11
60
|
type EmitOptions = {
|
|
@@ -54,19 +103,160 @@ createModuleEvents({
|
|
|
54
103
|
|
|
55
104
|
// req.signal is a linked/derived signal in Node, so we spy AFTER the
|
|
56
105
|
// Request is constructed to intercept the handler's real calls.
|
|
57
|
-
function makeTrackedRequest() {
|
|
106
|
+
function makeTrackedRequest(init: RequestInit = {}) {
|
|
58
107
|
const controller = new AbortController()
|
|
59
|
-
const req = new Request('http://localhost/api/events/stream', { signal: controller.signal })
|
|
108
|
+
const req = new Request('http://localhost/api/events/stream', { ...init, signal: controller.signal })
|
|
60
109
|
const addSpy = jest.spyOn(req.signal, 'addEventListener')
|
|
61
110
|
const removeSpy = jest.spyOn(req.signal, 'removeEventListener')
|
|
62
111
|
return { req, controller, addSpy, removeSpy }
|
|
63
112
|
}
|
|
64
113
|
|
|
114
|
+
function deferred<T>() {
|
|
115
|
+
let resolve!: (value: T) => void
|
|
116
|
+
let reject!: (reason?: unknown) => void
|
|
117
|
+
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
118
|
+
resolve = resolvePromise
|
|
119
|
+
reject = rejectPromise
|
|
120
|
+
})
|
|
121
|
+
return { promise, resolve, reject }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function flushPromises(): Promise<void> {
|
|
125
|
+
await Promise.resolve()
|
|
126
|
+
await Promise.resolve()
|
|
127
|
+
}
|
|
128
|
+
|
|
65
129
|
describe('SSE event stream — abort listener hygiene', () => {
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
mockResolveRequestContext.mockReset()
|
|
132
|
+
mockResolveRequestContext.mockResolvedValue(buildResolvedContext())
|
|
133
|
+
mockResolveOrganizationScopeForRequest.mockReset()
|
|
134
|
+
mockResolveOrganizationScopeForRequest.mockImplementation(async ({ auth }: MockOrganizationScopeInput) => {
|
|
135
|
+
const selectedId = auth?.orgId ?? null
|
|
136
|
+
return {
|
|
137
|
+
selectedId,
|
|
138
|
+
filterIds: selectedId ? [selectedId] : null,
|
|
139
|
+
allowedIds: selectedId ? [selectedId] : null,
|
|
140
|
+
tenantId: auth?.tenantId ?? null,
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
delete process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS
|
|
144
|
+
delete process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS
|
|
145
|
+
jest.spyOn(Math, 'random').mockReturnValue(0.5)
|
|
146
|
+
})
|
|
147
|
+
|
|
66
148
|
afterEach(() => {
|
|
149
|
+
jest.useRealTimers()
|
|
150
|
+
delete process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS
|
|
151
|
+
delete process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS
|
|
67
152
|
jest.restoreAllMocks()
|
|
68
153
|
})
|
|
69
154
|
|
|
155
|
+
it.each([
|
|
156
|
+
['x-api-key', { 'x-api-key': 'key-secret', cookie: 'auth_token=staff-token' }],
|
|
157
|
+
['Authorization ApiKey', { authorization: 'aPiKeY key-secret', cookie: 'auth_token=staff-token' }],
|
|
158
|
+
])('rejects %s credentials before resolving request context', async (_credential, headers) => {
|
|
159
|
+
const { req, addSpy } = makeTrackedRequest({ headers })
|
|
160
|
+
|
|
161
|
+
const response = await GET(req)
|
|
162
|
+
|
|
163
|
+
expect(response.status).toBe(401)
|
|
164
|
+
expect(mockResolveRequestContext).not.toHaveBeenCalled()
|
|
165
|
+
expect(addSpy).not.toHaveBeenCalled()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('rejects an API-key trusted context even when raw API-key headers are absent', async () => {
|
|
169
|
+
mockResolveRequestContext.mockResolvedValue(buildResolvedContext({
|
|
170
|
+
tenantId: 't1',
|
|
171
|
+
sub: 'api_key:key-1',
|
|
172
|
+
orgId: 'o1',
|
|
173
|
+
roles: ['admin'],
|
|
174
|
+
isApiKey: true,
|
|
175
|
+
}))
|
|
176
|
+
const { req, addSpy } = makeTrackedRequest()
|
|
177
|
+
|
|
178
|
+
const response = await GET(req)
|
|
179
|
+
|
|
180
|
+
expect(response.status).toBe(401)
|
|
181
|
+
expect(mockResolveRequestContext).toHaveBeenCalledTimes(1)
|
|
182
|
+
expect(addSpy).not.toHaveBeenCalled()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('rejects a selected organization that the canonical DI scope does not allow', async () => {
|
|
186
|
+
mockResolveOrganizationScopeForRequest.mockResolvedValue({
|
|
187
|
+
selectedId: 'o2',
|
|
188
|
+
filterIds: ['o1'],
|
|
189
|
+
allowedIds: ['o1'],
|
|
190
|
+
tenantId: 't1',
|
|
191
|
+
})
|
|
192
|
+
const { req, addSpy } = makeTrackedRequest({
|
|
193
|
+
headers: { cookie: 'auth_token=staff-token; om_selected_org=o2' },
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const response = await GET(req)
|
|
197
|
+
|
|
198
|
+
expect(response.status).toBe(401)
|
|
199
|
+
expect(mockResolveOrganizationScopeForRequest).toHaveBeenCalledWith({
|
|
200
|
+
auth: expect.objectContaining({ sub: 'u1' }),
|
|
201
|
+
request: req,
|
|
202
|
+
})
|
|
203
|
+
expect(addSpy).not.toHaveBeenCalled()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('rejects an initial scope whose normalized tenant differs from canonical auth without registering it', async () => {
|
|
207
|
+
mockResolveRequestContext.mockResolvedValue(buildResolvedContext({
|
|
208
|
+
tenantId: ' t1 ',
|
|
209
|
+
sub: 'u1',
|
|
210
|
+
orgId: 'o1',
|
|
211
|
+
roles: ['admin'],
|
|
212
|
+
}))
|
|
213
|
+
mockResolveOrganizationScopeForRequest.mockResolvedValue({
|
|
214
|
+
selectedId: 'o1',
|
|
215
|
+
filterIds: ['o1'],
|
|
216
|
+
allowedIds: ['o1'],
|
|
217
|
+
tenantId: ' t2 ',
|
|
218
|
+
})
|
|
219
|
+
const enqueueSpy = jest.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
|
|
220
|
+
const { req, addSpy } = makeTrackedRequest()
|
|
221
|
+
|
|
222
|
+
const response = await GET(req)
|
|
223
|
+
|
|
224
|
+
expect(response.status).toBe(401)
|
|
225
|
+
expect(registerGlobalEventTapMock).not.toHaveBeenCalled()
|
|
226
|
+
expect(enqueueSpy).not.toHaveBeenCalled()
|
|
227
|
+
expect(addSpy).not.toHaveBeenCalled()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it.each([
|
|
231
|
+
['staff cookie', { cookie: 'auth_token=cookie-token; om_selected_org=o1' }],
|
|
232
|
+
['staff Bearer token', { authorization: 'Bearer bearer-token' }],
|
|
233
|
+
])('copies %s credentials into a distinct canonical revalidation request', async (_credential, headers) => {
|
|
234
|
+
jest.useFakeTimers()
|
|
235
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '1000'
|
|
236
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = '10000'
|
|
237
|
+
const { req } = makeTrackedRequest({ headers })
|
|
238
|
+
const trustedContextSymbol = Symbol.for('open-mercato.auth.trustedContext')
|
|
239
|
+
;(req as unknown as Record<symbol, unknown>)[trustedContextSymbol] = { auth: { sub: 'stale' } }
|
|
240
|
+
const response = await GET(req)
|
|
241
|
+
const reader = response.body!.getReader()
|
|
242
|
+
await reader.read()
|
|
243
|
+
|
|
244
|
+
await jest.advanceTimersByTimeAsync(1000)
|
|
245
|
+
|
|
246
|
+
expect(mockResolveRequestContext).toHaveBeenCalledTimes(2)
|
|
247
|
+
const validationRequest = mockResolveRequestContext.mock.calls[1][0] as Request
|
|
248
|
+
expect(validationRequest).not.toBe(req)
|
|
249
|
+
expect(validationRequest.headers.get('cookie')).toBe(req.headers.get('cookie'))
|
|
250
|
+
expect(validationRequest.headers.get('authorization')).toBe(req.headers.get('authorization'))
|
|
251
|
+
expect((validationRequest as unknown as Record<symbol, unknown>)[trustedContextSymbol]).toBeUndefined()
|
|
252
|
+
expect(mockResolveOrganizationScopeForRequest).toHaveBeenCalledTimes(2)
|
|
253
|
+
expect(mockResolveOrganizationScopeForRequest.mock.calls[1][0]).toEqual({
|
|
254
|
+
auth: expect.objectContaining({ sub: 'u1' }),
|
|
255
|
+
request: validationRequest,
|
|
256
|
+
})
|
|
257
|
+
await reader.cancel()
|
|
258
|
+
})
|
|
259
|
+
|
|
70
260
|
it('registers the abort listener with { once: true }', async () => {
|
|
71
261
|
const { req, addSpy } = makeTrackedRequest()
|
|
72
262
|
const res = await GET(req)
|
|
@@ -109,6 +299,31 @@ describe('SSE event stream — abort listener hygiene', () => {
|
|
|
109
299
|
try { await (res.body as ReadableStream).cancel() } catch {}
|
|
110
300
|
})
|
|
111
301
|
|
|
302
|
+
it('fully cleans up an already-aborted request without registering a live connection', async () => {
|
|
303
|
+
jest.useFakeTimers()
|
|
304
|
+
const enqueueSpy = jest.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
|
|
305
|
+
const closeSpy = jest.spyOn(ReadableStreamDefaultController.prototype, 'close')
|
|
306
|
+
const { req, controller, addSpy, removeSpy } = makeTrackedRequest()
|
|
307
|
+
controller.abort()
|
|
308
|
+
|
|
309
|
+
const response = await GET(req)
|
|
310
|
+
const reader = response.body!.getReader()
|
|
311
|
+
await reader.read()
|
|
312
|
+
await expect(reader.read()).resolves.toEqual({ value: undefined, done: true })
|
|
313
|
+
const enqueueCountAfterClose = enqueueSpy.mock.calls.length
|
|
314
|
+
|
|
315
|
+
await mockGlobalEventTap?.(
|
|
316
|
+
'stream_privacy_test.browser',
|
|
317
|
+
{ tenantId: 't1', organizationId: 'o1', marker: 'must-not-arrive' },
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
expect(enqueueSpy).toHaveBeenCalledTimes(enqueueCountAfterClose)
|
|
321
|
+
expect(closeSpy).toHaveBeenCalledTimes(1)
|
|
322
|
+
expect(addSpy.mock.calls.filter((call) => call[0] === 'abort')).toHaveLength(1)
|
|
323
|
+
expect(removeSpy.mock.calls.filter((call) => call[0] === 'abort')).toHaveLength(1)
|
|
324
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
325
|
+
})
|
|
326
|
+
|
|
112
327
|
it('flushes an initial connected comment so EventSource opens immediately', async () => {
|
|
113
328
|
const { req } = makeTrackedRequest()
|
|
114
329
|
const res = await GET(req)
|
|
@@ -167,6 +382,103 @@ describe('SSE event stream — abort listener hygiene', () => {
|
|
|
167
382
|
try { await reader.cancel() } catch {}
|
|
168
383
|
})
|
|
169
384
|
|
|
385
|
+
it('still receives home-organization events when "All organizations" is selected', async () => {
|
|
386
|
+
// An unrestricted admin's organization switcher persists `om_selected_org=__all__`,
|
|
387
|
+
// so the resolved scope has NO concrete `selectedId`. Deriving the connection's
|
|
388
|
+
// organization from `selectedId` alone leaves it null, and `matchesAudience`
|
|
389
|
+
// then drops every organization-scoped event — the bridge goes silent on the
|
|
390
|
+
// default selection (TC-WF-059).
|
|
391
|
+
mockResolveOrganizationScopeForRequest.mockResolvedValue({
|
|
392
|
+
selectedId: null,
|
|
393
|
+
filterIds: null,
|
|
394
|
+
allowedIds: null,
|
|
395
|
+
tenantId: 't1',
|
|
396
|
+
})
|
|
397
|
+
const { req } = makeTrackedRequest({
|
|
398
|
+
headers: { cookie: 'auth_token=staff-token; om_selected_org=__all__' },
|
|
399
|
+
})
|
|
400
|
+
const res = await GET(req)
|
|
401
|
+
expect(res.status).toBe(200)
|
|
402
|
+
|
|
403
|
+
const reader = (res.body as ReadableStream<Uint8Array>).getReader()
|
|
404
|
+
await reader.read()
|
|
405
|
+
|
|
406
|
+
await mockGlobalEventTap?.(
|
|
407
|
+
'stream_privacy_test.browser',
|
|
408
|
+
{ tenantId: 't1', marker: 'all-orgs-expected' },
|
|
409
|
+
{ tenantId: 't1', organizationId: 'o1' },
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
const { value, done } = await reader.read()
|
|
413
|
+
expect(done).toBe(false)
|
|
414
|
+
expect(new TextDecoder().decode(value)).toContain('"marker":"all-orgs-expected"')
|
|
415
|
+
|
|
416
|
+
try { await reader.cancel() } catch {}
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('resolves ordinary multi-org staff scope through DI when replacing an O1 stream with O2', async () => {
|
|
420
|
+
const enqueueSpy = jest.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
|
|
421
|
+
const ordinaryStaffAuth: MockAuth = {
|
|
422
|
+
tenantId: 't1',
|
|
423
|
+
sub: 'u1',
|
|
424
|
+
orgId: 'o1',
|
|
425
|
+
roles: ['staff'],
|
|
426
|
+
isSuperAdmin: false,
|
|
427
|
+
}
|
|
428
|
+
mockResolveRequestContext.mockResolvedValue(buildResolvedContext(ordinaryStaffAuth))
|
|
429
|
+
mockResolveOrganizationScopeForRequest.mockImplementation(async ({ auth, request }: MockOrganizationScopeInput) => {
|
|
430
|
+
const cookie = request?.headers.get('cookie') ?? ''
|
|
431
|
+
const requestedId = cookie.includes('om_selected_org=o2') ? 'o2' : 'o1'
|
|
432
|
+
const allowedIds = ['o1', 'o2']
|
|
433
|
+
return {
|
|
434
|
+
selectedId: allowedIds.includes(requestedId) ? requestedId : 'o1',
|
|
435
|
+
filterIds: [requestedId],
|
|
436
|
+
allowedIds,
|
|
437
|
+
tenantId: auth?.tenantId ?? null,
|
|
438
|
+
...(allowedIds.includes(requestedId) ? {} : { selectionRejected: true }),
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
const { req: openingRequest } = makeTrackedRequest({
|
|
442
|
+
headers: { cookie: 'auth_token=staff-token; om_selected_org=o1' },
|
|
443
|
+
})
|
|
444
|
+
const openingResponse = await GET(openingRequest)
|
|
445
|
+
const openingReader = openingResponse.body!.getReader()
|
|
446
|
+
await openingReader.read()
|
|
447
|
+
await openingReader.cancel()
|
|
448
|
+
const enqueueCountAfterOpeningClose = enqueueSpy.mock.calls.length
|
|
449
|
+
|
|
450
|
+
const { req: replacementRequest } = makeTrackedRequest({
|
|
451
|
+
headers: { cookie: 'auth_token=staff-token; om_selected_org=o2' },
|
|
452
|
+
})
|
|
453
|
+
const replacementResponse = await GET(replacementRequest)
|
|
454
|
+
const replacementReader = replacementResponse.body!.getReader()
|
|
455
|
+
await replacementReader.read()
|
|
456
|
+
|
|
457
|
+
await mockGlobalEventTap?.(
|
|
458
|
+
'stream_privacy_test.browser',
|
|
459
|
+
{ tenantId: 't1', organizationId: 'o1', marker: 'closed-opening-scope' },
|
|
460
|
+
)
|
|
461
|
+
expect(enqueueSpy).toHaveBeenCalledTimes(enqueueCountAfterOpeningClose + 1)
|
|
462
|
+
await mockGlobalEventTap?.(
|
|
463
|
+
'stream_privacy_test.browser',
|
|
464
|
+
{ tenantId: 't1', organizationId: 'o2', marker: 'current-request-scope' },
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
const delivered = await replacementReader.read()
|
|
468
|
+
const decoded = new TextDecoder().decode(delivered.value)
|
|
469
|
+
expect(decoded).toContain('current-request-scope')
|
|
470
|
+
expect(decoded).not.toContain('closed-opening-scope')
|
|
471
|
+
expect(mockResolveOrganizationScopeForRequest).toHaveBeenNthCalledWith(1, {
|
|
472
|
+
auth: ordinaryStaffAuth,
|
|
473
|
+
request: openingRequest,
|
|
474
|
+
})
|
|
475
|
+
expect(mockResolveOrganizationScopeForRequest).toHaveBeenNthCalledWith(2, {
|
|
476
|
+
auth: ordinaryStaffAuth,
|
|
477
|
+
request: replacementRequest,
|
|
478
|
+
})
|
|
479
|
+
await replacementReader.cancel()
|
|
480
|
+
})
|
|
481
|
+
|
|
170
482
|
it('honors a trusted multi-organization audience for a clientBroadcast event', async () => {
|
|
171
483
|
const { req } = makeTrackedRequest()
|
|
172
484
|
const res = await GET(req)
|
|
@@ -486,4 +798,334 @@ describe('SSE event stream — abort listener hygiene', () => {
|
|
|
486
798
|
jest.restoreAllMocks()
|
|
487
799
|
}
|
|
488
800
|
})
|
|
801
|
+
|
|
802
|
+
it('closes and fully cleans up when canonical recipient roles change', async () => {
|
|
803
|
+
jest.useFakeTimers()
|
|
804
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '1000'
|
|
805
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = '10000'
|
|
806
|
+
const { req, addSpy, removeSpy } = makeTrackedRequest()
|
|
807
|
+
const response = await GET(req)
|
|
808
|
+
const reader = response.body!.getReader()
|
|
809
|
+
await reader.read()
|
|
810
|
+
const attachedListener = addSpy.mock.calls.find((call) => call[0] === 'abort')![1]
|
|
811
|
+
expect(jest.getTimerCount()).toBe(3)
|
|
812
|
+
|
|
813
|
+
mockResolveRequestContext.mockResolvedValue(buildResolvedContext({
|
|
814
|
+
tenantId: 't1',
|
|
815
|
+
sub: 'u1',
|
|
816
|
+
orgId: 'o1',
|
|
817
|
+
roles: ['viewer'],
|
|
818
|
+
}))
|
|
819
|
+
await jest.advanceTimersByTimeAsync(1000)
|
|
820
|
+
|
|
821
|
+
await mockGlobalEventTap?.(
|
|
822
|
+
'stream_privacy_test.browser',
|
|
823
|
+
{ tenantId: 't1', organizationId: 'o1', recipientRoleId: 'admin', marker: 'revoked-role' },
|
|
824
|
+
)
|
|
825
|
+
await expect(reader.read()).resolves.toEqual({ value: undefined, done: true })
|
|
826
|
+
expect(removeSpy).toHaveBeenCalledWith('abort', attachedListener)
|
|
827
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
it('closes on periodic canonical auth/scope tenant mismatch without delivery or resurrection', async () => {
|
|
831
|
+
jest.useFakeTimers()
|
|
832
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '1000'
|
|
833
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = '10000'
|
|
834
|
+
mockResolveOrganizationScopeForRequest
|
|
835
|
+
.mockResolvedValueOnce({
|
|
836
|
+
selectedId: 'o1',
|
|
837
|
+
filterIds: ['o1'],
|
|
838
|
+
allowedIds: ['o1'],
|
|
839
|
+
tenantId: 't1',
|
|
840
|
+
})
|
|
841
|
+
.mockResolvedValue({
|
|
842
|
+
selectedId: 'o1',
|
|
843
|
+
filterIds: ['o1'],
|
|
844
|
+
allowedIds: ['o1'],
|
|
845
|
+
tenantId: 't2',
|
|
846
|
+
})
|
|
847
|
+
const enqueueSpy = jest.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
|
|
848
|
+
const { req } = makeTrackedRequest()
|
|
849
|
+
const response = await GET(req)
|
|
850
|
+
const reader = response.body!.getReader()
|
|
851
|
+
await reader.read()
|
|
852
|
+
|
|
853
|
+
await jest.advanceTimersByTimeAsync(1000)
|
|
854
|
+
await expect(reader.read()).resolves.toEqual({ value: undefined, done: true })
|
|
855
|
+
const enqueueCountAfterClose = enqueueSpy.mock.calls.length
|
|
856
|
+
|
|
857
|
+
await mockGlobalEventTap?.(
|
|
858
|
+
'stream_privacy_test.browser',
|
|
859
|
+
{ tenantId: 't1', organizationId: 'o1', marker: 'must-not-arrive' },
|
|
860
|
+
)
|
|
861
|
+
await jest.advanceTimersByTimeAsync(5000)
|
|
862
|
+
|
|
863
|
+
expect(enqueueSpy).toHaveBeenCalledTimes(enqueueCountAfterClose)
|
|
864
|
+
expect(mockResolveRequestContext).toHaveBeenCalledTimes(2)
|
|
865
|
+
expect(mockResolveOrganizationScopeForRequest).toHaveBeenCalledTimes(2)
|
|
866
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it.each([
|
|
870
|
+
{
|
|
871
|
+
change: 'tenant move',
|
|
872
|
+
nextContext: buildResolvedContext({ tenantId: 't2', sub: 'u1', orgId: 'o1', roles: ['admin'] }),
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
change: 'organization move',
|
|
876
|
+
nextContext: buildResolvedContext({ tenantId: 't1', sub: 'u1', orgId: 'o2', roles: ['admin'] }),
|
|
877
|
+
},
|
|
878
|
+
])('closes after a canonical $change', async ({ nextContext }) => {
|
|
879
|
+
jest.useFakeTimers()
|
|
880
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '1000'
|
|
881
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = '10000'
|
|
882
|
+
const { req } = makeTrackedRequest()
|
|
883
|
+
const response = await GET(req)
|
|
884
|
+
const reader = response.body!.getReader()
|
|
885
|
+
await reader.read()
|
|
886
|
+
|
|
887
|
+
mockResolveRequestContext.mockResolvedValue(nextContext)
|
|
888
|
+
await jest.advanceTimersByTimeAsync(1000)
|
|
889
|
+
|
|
890
|
+
await expect(reader.read()).resolves.toEqual({ value: undefined, done: true })
|
|
891
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
it('closes when canonical identity is missing', async () => {
|
|
895
|
+
jest.useFakeTimers()
|
|
896
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '1000'
|
|
897
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = '10000'
|
|
898
|
+
const { req } = makeTrackedRequest()
|
|
899
|
+
const response = await GET(req)
|
|
900
|
+
const reader = response.body!.getReader()
|
|
901
|
+
await reader.read()
|
|
902
|
+
|
|
903
|
+
mockResolveRequestContext.mockResolvedValue(buildResolvedContext(null))
|
|
904
|
+
await jest.advanceTimersByTimeAsync(1000)
|
|
905
|
+
|
|
906
|
+
await expect(reader.read()).resolves.toEqual({ value: undefined, done: true })
|
|
907
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
it('fails closed when canonical auth validation throws', async () => {
|
|
911
|
+
jest.useFakeTimers()
|
|
912
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '1000'
|
|
913
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = '10000'
|
|
914
|
+
const { req } = makeTrackedRequest()
|
|
915
|
+
const response = await GET(req)
|
|
916
|
+
const reader = response.body!.getReader()
|
|
917
|
+
await reader.read()
|
|
918
|
+
|
|
919
|
+
mockResolveRequestContext.mockRejectedValue(new Error('validation unavailable'))
|
|
920
|
+
await jest.advanceTimersByTimeAsync(1000)
|
|
921
|
+
|
|
922
|
+
await expect(reader.read()).resolves.toEqual({ value: undefined, done: true })
|
|
923
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
it('caps connection lifetime and leaves EventSource free to reconnect', async () => {
|
|
927
|
+
jest.useFakeTimers()
|
|
928
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '10000'
|
|
929
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = '1000'
|
|
930
|
+
const { req, addSpy, removeSpy } = makeTrackedRequest()
|
|
931
|
+
const response = await GET(req)
|
|
932
|
+
const reader = response.body!.getReader()
|
|
933
|
+
await reader.read()
|
|
934
|
+
const attachedListener = addSpy.mock.calls.find((call) => call[0] === 'abort')![1]
|
|
935
|
+
|
|
936
|
+
await jest.advanceTimersByTimeAsync(1000)
|
|
937
|
+
|
|
938
|
+
await expect(reader.read()).resolves.toEqual({ value: undefined, done: true })
|
|
939
|
+
expect(mockResolveRequestContext).toHaveBeenCalledTimes(1)
|
|
940
|
+
expect(removeSpy).toHaveBeenCalledWith('abort', attachedListener)
|
|
941
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
it.each([
|
|
945
|
+
['the low end', 0, 8_500],
|
|
946
|
+
['the high end', 1, 11_500],
|
|
947
|
+
])('jitters the configured maximum age by 15%% at %s of the random range', async (_case, randomValue, expectedDelayMs) => {
|
|
948
|
+
jest.useFakeTimers()
|
|
949
|
+
jest.spyOn(Math, 'random').mockReturnValue(randomValue)
|
|
950
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '999999'
|
|
951
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = '10000'
|
|
952
|
+
const closeSpy = jest.spyOn(ReadableStreamDefaultController.prototype, 'close')
|
|
953
|
+
const { req } = makeTrackedRequest()
|
|
954
|
+
const response = await GET(req)
|
|
955
|
+
const reader = response.body!.getReader()
|
|
956
|
+
await reader.read()
|
|
957
|
+
|
|
958
|
+
jest.advanceTimersByTime(expectedDelayMs - 1)
|
|
959
|
+
expect(closeSpy).not.toHaveBeenCalled()
|
|
960
|
+
jest.advanceTimersByTime(1)
|
|
961
|
+
|
|
962
|
+
expect(closeSpy).toHaveBeenCalledTimes(1)
|
|
963
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
964
|
+
await reader.cancel()
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
it.each([
|
|
968
|
+
['the minimum age', '1000', 0, 1_000],
|
|
969
|
+
['the Node timer maximum', '2147483647', 1, 2_147_483_647],
|
|
970
|
+
])('keeps the jittered maximum age within %s', async (_case, configuredValue, randomValue, expectedDelayMs) => {
|
|
971
|
+
jest.useFakeTimers()
|
|
972
|
+
jest.spyOn(Math, 'random').mockReturnValue(randomValue)
|
|
973
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '999999'
|
|
974
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = configuredValue
|
|
975
|
+
const timeoutSpy = jest.spyOn(globalThis, 'setTimeout')
|
|
976
|
+
const { req } = makeTrackedRequest()
|
|
977
|
+
|
|
978
|
+
const response = await GET(req)
|
|
979
|
+
const reader = response.body!.getReader()
|
|
980
|
+
await reader.read()
|
|
981
|
+
|
|
982
|
+
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), expectedDelayMs)
|
|
983
|
+
await reader.cancel()
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
it.each(['0', 'not-a-number', 'Infinity'])('falls back to a finite maximum age for invalid value %s', async (invalidValue) => {
|
|
987
|
+
jest.useFakeTimers()
|
|
988
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '999999'
|
|
989
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = invalidValue
|
|
990
|
+
const closeSpy = jest.spyOn(ReadableStreamDefaultController.prototype, 'close')
|
|
991
|
+
const { req } = makeTrackedRequest()
|
|
992
|
+
const response = await GET(req)
|
|
993
|
+
const reader = response.body!.getReader()
|
|
994
|
+
await reader.read()
|
|
995
|
+
|
|
996
|
+
jest.advanceTimersByTime(299_999)
|
|
997
|
+
expect(closeSpy).not.toHaveBeenCalled()
|
|
998
|
+
jest.advanceTimersByTime(1)
|
|
999
|
+
|
|
1000
|
+
expect(closeSpy).toHaveBeenCalledTimes(1)
|
|
1001
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
1002
|
+
await reader.cancel()
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
it.each([
|
|
1006
|
+
['the exact Node timer maximum', '2147483647'],
|
|
1007
|
+
['one millisecond above the Node timer maximum', '2147483648'],
|
|
1008
|
+
['an oversized finite integer', '9007199254740991'],
|
|
1009
|
+
])('bounds SSE timers for %s', async (_case, configuredValue) => {
|
|
1010
|
+
jest.useFakeTimers()
|
|
1011
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = configuredValue
|
|
1012
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = configuredValue
|
|
1013
|
+
const intervalSpy = jest.spyOn(globalThis, 'setInterval')
|
|
1014
|
+
const timeoutSpy = jest.spyOn(globalThis, 'setTimeout')
|
|
1015
|
+
const { req } = makeTrackedRequest()
|
|
1016
|
+
|
|
1017
|
+
const response = await GET(req)
|
|
1018
|
+
const reader = response.body!.getReader()
|
|
1019
|
+
await reader.read()
|
|
1020
|
+
|
|
1021
|
+
expect(intervalSpy).toHaveBeenCalledWith(expect.any(Function), 2_147_483_647)
|
|
1022
|
+
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2_147_483_647)
|
|
1023
|
+
await reader.cancel()
|
|
1024
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
it('cannot resurrect or deliver after cancellation during deferred validation', async () => {
|
|
1028
|
+
jest.useFakeTimers()
|
|
1029
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '1000'
|
|
1030
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = '10000'
|
|
1031
|
+
const validation = deferred<ReturnType<typeof buildResolvedContext>>()
|
|
1032
|
+
mockResolveRequestContext
|
|
1033
|
+
.mockResolvedValueOnce(buildResolvedContext())
|
|
1034
|
+
.mockImplementationOnce(() => validation.promise)
|
|
1035
|
+
const enqueueSpy = jest.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
|
|
1036
|
+
const { req, addSpy, removeSpy } = makeTrackedRequest()
|
|
1037
|
+
const response = await GET(req)
|
|
1038
|
+
const reader = response.body!.getReader()
|
|
1039
|
+
await reader.read()
|
|
1040
|
+
const attachedListener = addSpy.mock.calls.find((call) => call[0] === 'abort')![1]
|
|
1041
|
+
|
|
1042
|
+
jest.advanceTimersByTime(1000)
|
|
1043
|
+
await flushPromises()
|
|
1044
|
+
expect(mockResolveRequestContext).toHaveBeenCalledTimes(2)
|
|
1045
|
+
await reader.cancel()
|
|
1046
|
+
const enqueueCountAfterCancel = enqueueSpy.mock.calls.length
|
|
1047
|
+
|
|
1048
|
+
validation.resolve(buildResolvedContext())
|
|
1049
|
+
await flushPromises()
|
|
1050
|
+
await mockGlobalEventTap?.(
|
|
1051
|
+
'stream_privacy_test.browser',
|
|
1052
|
+
{ tenantId: 't1', organizationId: 'o1', marker: 'must-not-arrive' },
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
expect(enqueueSpy).toHaveBeenCalledTimes(enqueueCountAfterCancel)
|
|
1056
|
+
expect(removeSpy.mock.calls.filter((call) => call[0] === 'abort' && call[1] === attachedListener)).toHaveLength(1)
|
|
1057
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
1058
|
+
})
|
|
1059
|
+
|
|
1060
|
+
it('cannot resurrect after maximum age closes during deferred validation', async () => {
|
|
1061
|
+
jest.useFakeTimers()
|
|
1062
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '1000'
|
|
1063
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = '1500'
|
|
1064
|
+
const validation = deferred<ReturnType<typeof buildResolvedContext>>()
|
|
1065
|
+
mockResolveRequestContext
|
|
1066
|
+
.mockResolvedValueOnce(buildResolvedContext())
|
|
1067
|
+
.mockImplementationOnce(() => validation.promise)
|
|
1068
|
+
const enqueueSpy = jest.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
|
|
1069
|
+
const closeSpy = jest.spyOn(ReadableStreamDefaultController.prototype, 'close')
|
|
1070
|
+
const { req, addSpy, removeSpy } = makeTrackedRequest()
|
|
1071
|
+
const response = await GET(req)
|
|
1072
|
+
const reader = response.body!.getReader()
|
|
1073
|
+
await reader.read()
|
|
1074
|
+
const attachedListener = addSpy.mock.calls.find((call) => call[0] === 'abort')![1]
|
|
1075
|
+
|
|
1076
|
+
jest.advanceTimersByTime(1000)
|
|
1077
|
+
await flushPromises()
|
|
1078
|
+
expect(mockResolveRequestContext).toHaveBeenCalledTimes(2)
|
|
1079
|
+
jest.advanceTimersByTime(500)
|
|
1080
|
+
await expect(reader.read()).resolves.toEqual({ value: undefined, done: true })
|
|
1081
|
+
const enqueueCountAfterClose = enqueueSpy.mock.calls.length
|
|
1082
|
+
|
|
1083
|
+
validation.resolve(buildResolvedContext())
|
|
1084
|
+
await flushPromises()
|
|
1085
|
+
await mockGlobalEventTap?.(
|
|
1086
|
+
'stream_privacy_test.browser',
|
|
1087
|
+
{ tenantId: 't1', organizationId: 'o1', marker: 'must-not-arrive' },
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
expect(enqueueSpy).toHaveBeenCalledTimes(enqueueCountAfterClose)
|
|
1091
|
+
expect(closeSpy).toHaveBeenCalledTimes(1)
|
|
1092
|
+
expect(removeSpy.mock.calls.filter((call) => call[0] === 'abort' && call[1] === attachedListener)).toHaveLength(1)
|
|
1093
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
1094
|
+
})
|
|
1095
|
+
|
|
1096
|
+
it('ignores a late validation failure after abort cleanup', async () => {
|
|
1097
|
+
jest.useFakeTimers()
|
|
1098
|
+
process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS = '1000'
|
|
1099
|
+
process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS = '10000'
|
|
1100
|
+
const validation = deferred<ReturnType<typeof buildResolvedContext>>()
|
|
1101
|
+
mockResolveRequestContext
|
|
1102
|
+
.mockResolvedValueOnce(buildResolvedContext())
|
|
1103
|
+
.mockImplementationOnce(() => validation.promise)
|
|
1104
|
+
const enqueueSpy = jest.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
|
|
1105
|
+
const closeSpy = jest.spyOn(ReadableStreamDefaultController.prototype, 'close')
|
|
1106
|
+
const { req, controller, addSpy, removeSpy } = makeTrackedRequest()
|
|
1107
|
+
const response = await GET(req)
|
|
1108
|
+
const reader = response.body!.getReader()
|
|
1109
|
+
await reader.read()
|
|
1110
|
+
const attachedListener = addSpy.mock.calls.find((call) => call[0] === 'abort')![1]
|
|
1111
|
+
|
|
1112
|
+
jest.advanceTimersByTime(1000)
|
|
1113
|
+
await flushPromises()
|
|
1114
|
+
controller.abort()
|
|
1115
|
+
await flushPromises()
|
|
1116
|
+
await expect(reader.read()).resolves.toEqual({ value: undefined, done: true })
|
|
1117
|
+
const enqueueCountAfterClose = enqueueSpy.mock.calls.length
|
|
1118
|
+
|
|
1119
|
+
validation.reject(new Error('late validation failure'))
|
|
1120
|
+
await flushPromises()
|
|
1121
|
+
await mockGlobalEventTap?.(
|
|
1122
|
+
'stream_privacy_test.browser',
|
|
1123
|
+
{ tenantId: 't1', organizationId: 'o1', marker: 'must-not-arrive' },
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
expect(enqueueSpy).toHaveBeenCalledTimes(enqueueCountAfterClose)
|
|
1127
|
+
expect(closeSpy).toHaveBeenCalledTimes(1)
|
|
1128
|
+
expect(removeSpy.mock.calls.filter((call) => call[0] === 'abort' && call[1] === attachedListener)).toHaveLength(1)
|
|
1129
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
1130
|
+
})
|
|
489
1131
|
})
|