From 7899ff304ec312b5db9237fe0ecd0232b4af2662 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 6 Sep 2026 15:34:28 +0800 Subject: [PATCH 1/2] fix: release tenant sessions after proof events Signed-off-by: Mark --- src/events/ProofEvents.ts | 10 +- src/events/__tests__/ProofEvents.test.ts | 145 +++++++++++++++++++++++ 2 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 src/events/__tests__/ProofEvents.test.ts diff --git a/src/events/ProofEvents.ts b/src/events/ProofEvents.ts index df6ebaed..76c596c7 100644 --- a/src/events/ProofEvents.ts +++ b/src/events/ProofEvents.ts @@ -1,3 +1,4 @@ +import type { RestMultiTenantAgentModules } from '../cliAgent' import type { ServerConfig } from '../utils/ServerConfig' import type { Agent } from '@credo-ts/core' import type { DidCommProofStateChangedEvent } from '@credo-ts/didcomm' @@ -12,11 +13,10 @@ export const proofEvents = async (agent: Agent, config: ServerConfig) => { const record = event.payload.proofRecord const body = { ...record.toJSON(), ...event.metadata } as { proofData?: any } if (event.metadata.contextCorrelationId && event.metadata.contextCorrelationId !== 'default') { - const tenantAgent = await agent.modules.tenants.getTenantAgent({ - tenantId: event.metadata.contextCorrelationId.split('tenant-')[1], - }) - const data = await tenantAgent.modules.didcomm.proofs.getFormatData(record.id) - body.proofData = data + body.proofData = await (agent as Agent).modules.tenants.withTenantAgent( + { tenantId: event.metadata.contextCorrelationId.split('tenant-')[1] }, + (tenantAgent) => tenantAgent.modules.didcomm.proofs.getFormatData(record.id), + ) } //Emit webhook for dedicated agent diff --git a/src/events/__tests__/ProofEvents.test.ts b/src/events/__tests__/ProofEvents.test.ts new file mode 100644 index 00000000..1ae3c978 --- /dev/null +++ b/src/events/__tests__/ProofEvents.test.ts @@ -0,0 +1,145 @@ +import type { ServerConfig } from '../../utils/ServerConfig' +import type { Agent } from '@credo-ts/core' +import type { DidCommProofStateChangedEvent } from '@credo-ts/didcomm' + +import { DidCommProofEventTypes } from '@credo-ts/didcomm' + +import { proofEvents } from '../ProofEvents' +import { sendWebSocketEvent } from '../WebSocketEvents' +import { sendWebhookEvent } from '../WebhookEvent' + +jest.mock('../WebSocketEvents', () => ({ sendWebSocketEvent: jest.fn() })) +jest.mock('../WebhookEvent', () => ({ sendWebhookEvent: jest.fn() })) +jest.mock('@credo-ts/didcomm', () => ({ DidCommProofEventTypes: { ProofStateChanged: 'DidCommProofStateChanged' } })) + +const createFixture = async () => { + let activeSessions = 0 + const logger = { debug: jest.fn(), error: jest.fn() } + const getFormatData = jest.fn().mockResolvedValue({ presentation: { anoncreds: { revealed: 'value' } } }) + const endSession = jest.fn(async () => { + activeSessions-- + }) + const getTenantAgent = jest.fn(async (_options: { tenantId: string }) => { + if (activeSessions === 1) throw new Error('Tenant session limit reached') + activeSessions++ + return { modules: { didcomm: { proofs: { getFormatData } } }, endSession } + }) + // Model Credo's scoped-session contract without loading its ESM/native dependencies in Jest. + const tenants = { + getTenantAgent, + async withTenantAgent( + options: { tenantId: string }, + callback: (tenantAgent: Awaited>) => Promise, + ) { + const tenantAgent = await getTenantAgent(options) + try { + return await callback(tenantAgent) + } finally { + await tenantAgent.endSession() + } + }, + } + const getDedicatedFormatData = jest.fn().mockResolvedValue({ presentation: { dedicated: true } }) + const on = jest.fn Promise]>() + const agent = { + events: { on }, + modules: { tenants, didcomm: { proofs: { getFormatData: getDedicatedFormatData } } }, + config: { logger }, + } as unknown as Agent + const config: ServerConfig = { + port: 3000, + webhookUrl: 'https://example.com/hooks', + socketServer: {} as NonNullable, + } + await proofEvents(agent, config) + const handleEvent = on.mock.calls[0][1] + const event = { + type: DidCommProofEventTypes.ProofStateChanged, + metadata: { contextCorrelationId: 'tenant-test-tenant' }, + payload: { proofRecord: { id: 'proof-id', toJSON: () => ({ id: 'proof-id', state: 'done' }) } }, + } as unknown as DidCommProofStateChangedEvent + + return { + handleEvent, + event, + config, + logger, + getFormatData, + getDedicatedFormatData, + getTenantAgent, + endSession, + activeSessions: () => activeSessions, + } +} + +describe('proofEvents tenant session lifecycle', () => { + beforeEach(() => jest.clearAllMocks()) + + test('releases each tenant session so consecutive proof events do not exhaust the limit', async () => { + const fixture = await createFixture() + + for (let index = 0; index < 3; index++) { + await fixture.handleEvent(fixture.event) + } + + expect(fixture.getTenantAgent).toHaveBeenCalledTimes(3) + expect(fixture.getTenantAgent).toHaveBeenCalledWith({ tenantId: 'test-tenant' }) + expect(fixture.endSession).toHaveBeenCalledTimes(3) + expect(fixture.activeSessions()).toBe(0) + expect(sendWebhookEvent).toHaveBeenCalledTimes(3) + expect(sendWebSocketEvent).toHaveBeenCalledTimes(3) + }) + + test('releases the session before delivery and preserves the proof payload', async () => { + const fixture = await createFixture() + jest.mocked(sendWebhookEvent).mockImplementationOnce(async () => { + expect(fixture.activeSessions()).toBe(0) + }) + + await fixture.handleEvent(fixture.event) + + const body = { + id: 'proof-id', + state: 'done', + contextCorrelationId: 'tenant-test-tenant', + proofData: await fixture.getFormatData.mock.results[0].value, + } + expect(fixture.getFormatData).toHaveBeenCalledWith('proof-id') + expect(sendWebhookEvent).toHaveBeenCalledWith('https://example.com/hooks/proofs', body, fixture.logger) + expect(sendWebSocketEvent).toHaveBeenCalledWith(fixture.config.socketServer, { + ...fixture.event, + payload: { ...fixture.event.payload, proofRecord: body }, + }) + }) + + test('releases the session when reading proof data fails and allows a subsequent event', async () => { + const fixture = await createFixture() + const error = new Error('Proof data unavailable') + fixture.getFormatData.mockRejectedValueOnce(error) + + await expect(fixture.handleEvent(fixture.event)).rejects.toBe(error) + + expect(fixture.endSession).toHaveBeenCalledTimes(1) + expect(fixture.activeSessions()).toBe(0) + expect(sendWebhookEvent).not.toHaveBeenCalled() + expect(sendWebSocketEvent).not.toHaveBeenCalled() + await expect(fixture.handleEvent(fixture.event)).resolves.toBeUndefined() + expect(fixture.endSession).toHaveBeenCalledTimes(2) + }) + + test('keeps dedicated-agent proof processing independent of tenant sessions', async () => { + const fixture = await createFixture() + fixture.event.metadata.contextCorrelationId = 'default' + + await fixture.handleEvent(fixture.event) + + expect(fixture.getDedicatedFormatData).toHaveBeenCalledWith('proof-id') + expect(fixture.getTenantAgent).not.toHaveBeenCalled() + expect(fixture.endSession).not.toHaveBeenCalled() + expect(sendWebhookEvent).toHaveBeenCalledWith( + 'https://example.com/hooks/proofs', + expect.objectContaining({ contextCorrelationId: 'default', proofData: { presentation: { dedicated: true } } }), + fixture.logger, + ) + }) +}) From 4ef34750d6e40658ad2c3bacbf42589adfbc9490 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 12 Sep 2026 17:50:58 +0800 Subject: [PATCH 2/2] test: verify proof delivery recovers after read failure Signed-off-by: Mark --- src/events/__tests__/ProofEvents.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/events/__tests__/ProofEvents.test.ts b/src/events/__tests__/ProofEvents.test.ts index 1ae3c978..c4a6f1aa 100644 --- a/src/events/__tests__/ProofEvents.test.ts +++ b/src/events/__tests__/ProofEvents.test.ts @@ -125,6 +125,8 @@ describe('proofEvents tenant session lifecycle', () => { expect(sendWebSocketEvent).not.toHaveBeenCalled() await expect(fixture.handleEvent(fixture.event)).resolves.toBeUndefined() expect(fixture.endSession).toHaveBeenCalledTimes(2) + expect(sendWebhookEvent).toHaveBeenCalledTimes(1) + expect(sendWebSocketEvent).toHaveBeenCalledTimes(1) }) test('keeps dedicated-agent proof processing independent of tenant sessions', async () => {