@learncard/network-brain-service 3.18.0 → 3.19.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/.env.example +4 -0
- package/CHANGELOG.md +80 -0
- package/compose.inbox.yml +7 -0
- package/dist/accesslayer/auth-grant/read.d.ts +1 -0
- package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
- package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
- package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
- package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
- package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
- package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
- package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
- package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
- package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
- package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
- package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
- package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
- package/dist/cache/atomic-counter.test.d.ts +2 -0
- package/dist/cache/atomic-counter.test.d.ts.map +1 -0
- package/dist/cache/index.d.ts +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/config/environment.d.ts +46 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/helpers/activity.helpers.d.ts +4 -0
- package/dist/helpers/activity.helpers.d.ts.map +1 -1
- package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
- package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
- package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
- package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
- package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
- package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/credential.helpers.d.ts +2 -1
- package/dist/helpers/credential.helpers.d.ts.map +1 -1
- package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
- package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
- package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
- package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
- package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
- package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
- package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox.helpers.d.ts +9 -1
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
- package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
- package/dist/helpers/shim.d.ts +1 -1
- package/dist/helpers/shim.d.ts.map +1 -1
- package/dist/inbox-queue-local.d.ts +2 -0
- package/dist/inbox-queue-local.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/AuthGrant.d.ts.map +1 -1
- package/dist/models/CredentialActivity.d.ts +1 -0
- package/dist/models/CredentialActivity.d.ts.map +1 -1
- package/dist/models/CredentialRefresh.d.ts +5 -2
- package/dist/models/CredentialRefresh.d.ts.map +1 -1
- package/dist/models/InboxCredential.d.ts +1 -0
- package/dist/models/InboxCredential.d.ts.map +1 -1
- package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/routes/act-as.test.d.ts +2 -0
- package/dist/routes/act-as.test.d.ts.map +1 -0
- package/dist/routes/activity.d.ts +3 -0
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/auth-grants.d.ts +10 -0
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +494 -0
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +953 -11
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +42 -126
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
- package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
- package/dist/services/registry/registry.factory.d.ts.map +1 -1
- package/dist/types/activity.d.ts +4 -0
- package/dist/types/activity.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +10 -1
- package/dist/types/credential-refresh.d.ts.map +1 -1
- package/dist/types/inbox-batch.d.ts +38 -0
- package/dist/types/inbox-batch.d.ts.map +1 -0
- package/inbox-e2e-setup.ts +53 -1
- package/inbox-elasticmq.conf +10 -0
- package/lambda.ts +61 -16
- package/package.json +18 -16
- package/scripts/setup-inbox-queues.ts +60 -0
- package/serverless-local.yml +2 -0
- package/serverless.yml +75 -0
- package/src/accesslayer/auth-grant/read.ts +2 -1
- package/src/accesslayer/credential-activity/create.ts +1 -0
- package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
- package/src/accesslayer/credential-refresh/index.ts +1 -0
- package/src/accesslayer/credential-refresh/read.ts +8 -2
- package/src/accesslayer/credential-refresh/update.ts +4 -0
- package/src/accesslayer/inbox-batch/store.ts +469 -0
- package/src/accesslayer/inbox-credential/create.ts +51 -19
- package/src/accesslayer/inbox-credential/refresh.ts +221 -0
- package/src/accesslayer/inbox-credential/update.ts +1 -1
- package/src/cache/atomic-counter.test-helpers.ts +27 -0
- package/src/cache/atomic-counter.test.ts +6 -0
- package/src/cache/index.ts +25 -13
- package/src/config/environment.ts +26 -0
- package/src/credential-refresh.ts +5 -1
- package/src/docker-entry.ts +8 -0
- package/src/helpers/activity.helpers.ts +4 -0
- package/src/helpers/app-counter.helpers.ts +1 -1
- package/src/helpers/contact-method.helpers.test.ts +43 -0
- package/src/helpers/contact-method.helpers.ts +3 -4
- package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
- package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
- package/src/helpers/credential-refresh-email.helpers.ts +201 -0
- package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
- package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
- package/src/helpers/credential-refresh.helpers.ts +375 -99
- package/src/helpers/credential.helpers.ts +4 -1
- package/src/helpers/finalize-inbox.helpers.ts +29 -14
- package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
- package/src/helpers/inbox-batch-http.helpers.ts +150 -0
- package/src/helpers/inbox-batch-status.helpers.ts +11 -0
- package/src/helpers/inbox-batch.helpers.test.ts +668 -0
- package/src/helpers/inbox-batch.helpers.ts +374 -0
- package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
- package/src/helpers/inbox-queue.helpers.test.ts +253 -0
- package/src/helpers/inbox-queue.helpers.ts +372 -0
- package/src/helpers/inbox-refresh.helpers.ts +432 -0
- package/src/helpers/inbox.helpers.ts +268 -32
- package/src/helpers/notifications.helpers.ts +4 -2
- package/src/helpers/rateLimit.helpers.ts +2 -5
- package/src/helpers/refresh-send-intent.helpers.ts +273 -0
- package/src/helpers/shim.ts +32 -31
- package/src/inbox-queue-local.ts +28 -0
- package/src/models/AuthGrant.ts +1 -0
- package/src/models/CredentialActivity.ts +3 -5
- package/src/models/CredentialRefresh.ts +4 -2
- package/src/models/InboxCredential.ts +2 -0
- package/src/models/credential-refresh-constraints.test.ts +8 -3
- package/src/models/credential-refresh-constraints.ts +5 -0
- package/src/openapi.ts +8 -0
- package/src/routes/act-as.test.ts +239 -0
- package/src/routes/app-store.ts +2 -2
- package/src/routes/auth-grants.ts +13 -11
- package/src/routes/boosts.ts +914 -92
- package/src/routes/credential-refreshes.test.ts +8 -2
- package/src/routes/credential-refreshes.ts +4 -1
- package/src/routes/credentials.ts +2 -1
- package/src/routes/inbox.ts +95 -49
- package/src/routes/index.ts +82 -7
- package/src/routes/skills.ts +1 -1
- package/src/routes/workflows.ts +43 -24
- package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
- package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
- package/src/services/registry/registry.factory.ts +2 -1
- package/src/types/activity.ts +2 -0
- package/src/types/credential-refresh.ts +21 -2
- package/src/types/inbox-batch.ts +46 -0
- package/test/atomic-counter.e2e.ts +12 -0
- package/test/auth-grant.spec.ts +21 -21
- package/test/credential-refresh-email.spec.ts +663 -0
- package/test/credential-refresh-publication.spec.ts +2 -0
- package/test/helpers/inbox-e2e-environment.ts +6 -0
- package/test/inbox-batch.spec.ts +1338 -0
- package/test/inbox-refresh.spec.ts +534 -0
- package/test/openapi.spec.ts +41 -1
- package/test/refresh-send.spec.ts +1799 -0
- package/test/registry.spec.ts +56 -11
- package/vitest.config.ts +9 -0
- package/vitest.inbox-e2e.config.ts +5 -1
- package/vitest.integration.config.ts +2 -0
|
@@ -0,0 +1,1338 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import Fastify, { type FastifyInstance } from 'fastify';
|
|
3
|
+
import { fastifyTRPCOpenApiPlugin } from 'trpc-to-openapi';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import type { InboxBatchReceipt, IssueInboxCredentialBatch, VC, VP } from '@learncard/types';
|
|
6
|
+
import { appRouter, createContext } from '../src/app';
|
|
7
|
+
import { getClient, getUser } from './helpers/getClient';
|
|
8
|
+
import { testUnsignedBoost } from './helpers/send';
|
|
9
|
+
import { sendSpy, addNotificationToQueueSpy } from './helpers/spies';
|
|
10
|
+
import { Profile, InboxCredential, ContactMethod, SigningAuthority, Boost } from '@models';
|
|
11
|
+
import cache from '@cache';
|
|
12
|
+
import { neogma } from '@instance';
|
|
13
|
+
import {
|
|
14
|
+
configureInboxBatchBodyLimit,
|
|
15
|
+
inboxBatchResponseMeta,
|
|
16
|
+
} from '@helpers/inbox-batch-http.helpers';
|
|
17
|
+
import { setValidChallengeForDid } from '@cache/challenges';
|
|
18
|
+
import { getInboxCredentialById } from '@accesslayer/inbox-credential/read';
|
|
19
|
+
import { createContactMethod } from '@accesslayer/contact-method/create';
|
|
20
|
+
import { createProfileContactMethodRelationship } from '@accesslayer/contact-method/relationships/create';
|
|
21
|
+
import { decryptInboxCredential } from '@helpers/inbox-encryption.helpers';
|
|
22
|
+
import * as notifications from '@helpers/notifications.helpers';
|
|
23
|
+
import { clrWestbridgeFull } from '../../../../packages/credential-library/src/fixtures/clr/westbridge-full';
|
|
24
|
+
import * as signing from '@helpers/signingAuthority.helpers';
|
|
25
|
+
import {
|
|
26
|
+
dispatchInboxJobs,
|
|
27
|
+
consumeInboxQueueOnce,
|
|
28
|
+
consumeInboxDeadLettersOnce,
|
|
29
|
+
} from '@helpers/inbox-queue.helpers';
|
|
30
|
+
import { fingerprint } from '@helpers/inbox-batch.helpers';
|
|
31
|
+
import * as jobStore from '@accesslayer/inbox-batch/store';
|
|
32
|
+
import * as issuance from '@helpers/inbox.helpers';
|
|
33
|
+
import { SQSClient, SendMessageCommand, SendMessageBatchCommand } from '@aws-sdk/client-sqs';
|
|
34
|
+
|
|
35
|
+
vi.mock('@services/delivery/delivery.factory', () => ({
|
|
36
|
+
getDeliveryService: () => ({ send: sendSpy }),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock('@services/registry/registry.factory', () => ({
|
|
40
|
+
getRegistryService: () => ({ isTrusted: async () => false }),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
describe('Universal Inbox batch issuance', () => {
|
|
44
|
+
let issuer: Awaited<ReturnType<typeof getUser>>;
|
|
45
|
+
let holder: Awaited<ReturnType<typeof getUser>>;
|
|
46
|
+
let server: FastifyInstance;
|
|
47
|
+
const signingAuthority = { endpoint: 'http://localhost:5000/api', name: 'batch-sa' };
|
|
48
|
+
const email = (value: string) => ({ type: 'email' as const, value });
|
|
49
|
+
const unsigned = async () => issuer.learnCard.invoke.getTestVc();
|
|
50
|
+
const signed = async () => issuer.learnCard.invoke.issueCredential(await unsigned());
|
|
51
|
+
const poll = async (batchId: string) => {
|
|
52
|
+
const deadline = Date.now() + 60_000;
|
|
53
|
+
while (Date.now() < deadline) {
|
|
54
|
+
// Exercise real SQS publication/consumption, not a direct issuance shortcut.
|
|
55
|
+
await dispatchInboxJobs();
|
|
56
|
+
await consumeInboxQueueOnce(0);
|
|
57
|
+
const status = await issuer.clients.fullAuth.inbox.getBatch({ batchId });
|
|
58
|
+
if (status.summary.pending === 0) {
|
|
59
|
+
const { total, succeeded, failed, deduplicated } = status.summary;
|
|
60
|
+
return {
|
|
61
|
+
results: status.items.map(item => item.result!),
|
|
62
|
+
summary: { total, succeeded, failed, deduplicated },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`Timed out polling inbox batch ${batchId}`);
|
|
68
|
+
};
|
|
69
|
+
const issue = async (batch: IssueInboxCredentialBatch) => {
|
|
70
|
+
const response = await post(batch);
|
|
71
|
+
expect(response.statusCode, response.body).toBe(202);
|
|
72
|
+
return poll(response.json().batchId);
|
|
73
|
+
};
|
|
74
|
+
const replay = async (key: string) => {
|
|
75
|
+
const rows = await neogma.queryRunner.run('MATCH (r:InboxBatchReplay {id: $id}) RETURN r', {
|
|
76
|
+
id: `client:${fingerprint(['batch-issuer', key])}`,
|
|
77
|
+
});
|
|
78
|
+
return rows.records[0]?.get('r').properties;
|
|
79
|
+
};
|
|
80
|
+
const stored = async (id: string) => {
|
|
81
|
+
const record = await getInboxCredentialById(id);
|
|
82
|
+
expect(record).toBeDefined();
|
|
83
|
+
return record!;
|
|
84
|
+
};
|
|
85
|
+
const post = async (body: unknown, authenticated = true, path = '/api/inbox/issue-batch') => {
|
|
86
|
+
const challenge = randomUUID();
|
|
87
|
+
await setValidChallengeForDid(issuer.learnCard.id.did(), challenge);
|
|
88
|
+
const token = await issuer.learnCard.invoke.getDidAuthVp({ proofFormat: 'jwt', challenge });
|
|
89
|
+
return server.inject({
|
|
90
|
+
method: 'POST',
|
|
91
|
+
url: path,
|
|
92
|
+
payload: body as object,
|
|
93
|
+
headers: authenticated
|
|
94
|
+
? { authorization: `Bearer ${token}`, 'x-tenant-id': 'scoutpass' }
|
|
95
|
+
: {},
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
const claim = async (claimUrl: string): Promise<VC[]> => {
|
|
99
|
+
const localExchangeId = new URL(claimUrl).pathname.split('/').pop()!;
|
|
100
|
+
const start = await holder.clients.fullAuth.workflows.participateInExchange({
|
|
101
|
+
localWorkflowId: 'inbox-claim',
|
|
102
|
+
localExchangeId,
|
|
103
|
+
});
|
|
104
|
+
const vp = (await holder.learnCard.invoke.getDidAuthVp({
|
|
105
|
+
challenge: start.verifiablePresentationRequest?.challenge,
|
|
106
|
+
domain: start.verifiablePresentationRequest?.domain,
|
|
107
|
+
})) as VP;
|
|
108
|
+
const result = await holder.clients.fullAuth.workflows.participateInExchange({
|
|
109
|
+
localWorkflowId: 'inbox-claim',
|
|
110
|
+
localExchangeId,
|
|
111
|
+
verifiablePresentation: vp,
|
|
112
|
+
});
|
|
113
|
+
return result.verifiablePresentation?.verifiableCredential as VC[];
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
beforeAll(async () => {
|
|
117
|
+
issuer = await getUser('a'.repeat(64));
|
|
118
|
+
holder = await getUser('b'.repeat(64));
|
|
119
|
+
server = Fastify({ routerOptions: { maxParamLength: 5000 } });
|
|
120
|
+
configureInboxBatchBodyLimit(server);
|
|
121
|
+
await server.register(fastifyTRPCOpenApiPlugin, {
|
|
122
|
+
responseMeta: inboxBatchResponseMeta,
|
|
123
|
+
basePath: '/api',
|
|
124
|
+
router: appRouter,
|
|
125
|
+
createContext,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
beforeEach(async () => {
|
|
129
|
+
vi.restoreAllMocks();
|
|
130
|
+
vi.unstubAllEnvs();
|
|
131
|
+
sendSpy.mockClear();
|
|
132
|
+
addNotificationToQueueSpy.mockClear();
|
|
133
|
+
vi.spyOn(notifications, 'addNotificationToQueue').mockImplementation(
|
|
134
|
+
addNotificationToQueueSpy
|
|
135
|
+
);
|
|
136
|
+
for (const model of [Profile, InboxCredential, ContactMethod, SigningAuthority, Boost]) {
|
|
137
|
+
await model.delete({ detach: true, where: {} });
|
|
138
|
+
}
|
|
139
|
+
await neogma.queryRunner.run(
|
|
140
|
+
'MATCH (n) WHERE n:InboxBatch OR n:InboxBatchItem OR n:InboxBatchIssuer OR n:InboxBatchReplay DETACH DELETE n'
|
|
141
|
+
);
|
|
142
|
+
const keys = await cache.keys('inbox-batch-*');
|
|
143
|
+
if (keys?.length) await cache.delete(keys);
|
|
144
|
+
await issuer.clients.fullAuth.profile.createProfile({ profileId: 'batch-issuer' });
|
|
145
|
+
await holder.clients.fullAuth.profile.createProfile({ profileId: 'batch-holder' });
|
|
146
|
+
await issuer.clients.fullAuth.profile.registerSigningAuthority({
|
|
147
|
+
...signingAuthority,
|
|
148
|
+
did: issuer.learnCard.id.did(),
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
afterAll(async () => {
|
|
152
|
+
vi.restoreAllMocks();
|
|
153
|
+
vi.unstubAllEnvs();
|
|
154
|
+
await server?.close();
|
|
155
|
+
await cache.redis?.quit();
|
|
156
|
+
await neogma.driver.close();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const submit = async (batch: IssueInboxCredentialBatch) => {
|
|
160
|
+
const response = await post(batch);
|
|
161
|
+
expect(response.statusCode, response.body).toBe(202);
|
|
162
|
+
return response.json() as InboxBatchReceipt;
|
|
163
|
+
};
|
|
164
|
+
const statusOf = (batchId: string) => issuer.clients.fullAuth.inbox.getBatch({ batchId });
|
|
165
|
+
const expireLease = (batchId: string) =>
|
|
166
|
+
neogma.queryRunner.run(
|
|
167
|
+
'MATCH (i:InboxBatchItem {batchId: $batchId}) SET i.leaseUntil = 0',
|
|
168
|
+
{ batchId }
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
it('accepts a one-item batch without waiting for a slow worker and restricts polling to its issuer', async () => {
|
|
172
|
+
const receipt = await submit({
|
|
173
|
+
items: [{ recipient: email('slow@test.com'), credential: await signed() }],
|
|
174
|
+
});
|
|
175
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
176
|
+
const challenge = randomUUID();
|
|
177
|
+
await setValidChallengeForDid(issuer.learnCard.id.did(), challenge);
|
|
178
|
+
const token = await issuer.learnCard.invoke.getDidAuthVp({ proofFormat: 'jwt', challenge });
|
|
179
|
+
const httpProgress = await server.inject({
|
|
180
|
+
method: 'GET',
|
|
181
|
+
url: `/api/inbox/batches/${receipt.batchId}`,
|
|
182
|
+
headers: { authorization: `Bearer ${token}` },
|
|
183
|
+
});
|
|
184
|
+
expect(httpProgress.statusCode, httpProgress.body).toBe(200);
|
|
185
|
+
expect(httpProgress.json()).toMatchObject({ batchId: receipt.batchId, status: 'QUEUED' });
|
|
186
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({
|
|
187
|
+
status: 'QUEUED',
|
|
188
|
+
summary: { pending: 1 },
|
|
189
|
+
});
|
|
190
|
+
await expect(
|
|
191
|
+
holder.clients.fullAuth.inbox.getBatch({ batchId: receipt.batchId })
|
|
192
|
+
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
193
|
+
await expect(
|
|
194
|
+
getClient({
|
|
195
|
+
did: issuer.learnCard.id.did(),
|
|
196
|
+
isChallengeValid: true,
|
|
197
|
+
scope: 'inbox:write',
|
|
198
|
+
}).inbox.getBatch({ batchId: receipt.batchId })
|
|
199
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
200
|
+
let release!: () => void;
|
|
201
|
+
let started!: () => void;
|
|
202
|
+
const blocked = new Promise<void>(resolve => {
|
|
203
|
+
release = resolve;
|
|
204
|
+
});
|
|
205
|
+
const entered = new Promise<void>(resolve => {
|
|
206
|
+
started = resolve;
|
|
207
|
+
});
|
|
208
|
+
const original = issuance.resolveInboxCredentialInput;
|
|
209
|
+
vi.spyOn(issuance, 'resolveInboxCredentialInput').mockImplementationOnce(
|
|
210
|
+
async (...args) => {
|
|
211
|
+
started();
|
|
212
|
+
await blocked;
|
|
213
|
+
return original(...args);
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
await dispatchInboxJobs();
|
|
217
|
+
const worker = consumeInboxQueueOnce(0);
|
|
218
|
+
try {
|
|
219
|
+
await entered;
|
|
220
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({
|
|
221
|
+
status: 'PROCESSING',
|
|
222
|
+
summary: { pending: 1 },
|
|
223
|
+
});
|
|
224
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
225
|
+
} finally {
|
|
226
|
+
release();
|
|
227
|
+
await worker;
|
|
228
|
+
}
|
|
229
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({
|
|
230
|
+
status: 'COMPLETED',
|
|
231
|
+
summary: { succeeded: 1 },
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('atomically replays concurrent request IDs without a second quota charge', async () => {
|
|
236
|
+
const batch = {
|
|
237
|
+
requestId: randomUUID(),
|
|
238
|
+
items: [{ recipient: email('request@test.com'), credential: await signed() }],
|
|
239
|
+
};
|
|
240
|
+
const receipts = await Promise.all(Array.from({ length: 5 }, () => submit(batch)));
|
|
241
|
+
expect(new Set(receipts.map(r => r.batchId)).size).toBe(1);
|
|
242
|
+
const quota = await neogma.queryRunner.run(
|
|
243
|
+
'MATCH (q:InboxBatchIssuer {id: "batch-issuer"}) RETURN q.used AS used'
|
|
244
|
+
);
|
|
245
|
+
expect(Number(quota.records[0]!.get('used'))).toBe(1);
|
|
246
|
+
const conflict = await post({
|
|
247
|
+
...batch,
|
|
248
|
+
items: [{ ...batch.items[0], recipient: email('changed@test.com') }],
|
|
249
|
+
});
|
|
250
|
+
expect(conflict.statusCode).toBe(409);
|
|
251
|
+
expect((await poll(receipts[0]!.batchId)).summary.succeeded).toBe(1);
|
|
252
|
+
expect(await submit(batch)).toMatchObject({
|
|
253
|
+
batchId: receipts[0]!.batchId,
|
|
254
|
+
status: 'COMPLETED',
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('reports live processing and reconciliation states when replaying a request ID', async () => {
|
|
259
|
+
const batch = {
|
|
260
|
+
requestId: randomUUID(),
|
|
261
|
+
items: [{ recipient: email('receipt@test.com'), credential: await signed() }],
|
|
262
|
+
};
|
|
263
|
+
const receipt = await submit(batch);
|
|
264
|
+
expect(receipt.status).toBe('QUEUED');
|
|
265
|
+
const id = `${receipt.batchId}:0`;
|
|
266
|
+
await jobStore.claimBatchItem(id, 'worker');
|
|
267
|
+
expect(await submit(batch)).toMatchObject({
|
|
268
|
+
batchId: receipt.batchId,
|
|
269
|
+
status: 'PROCESSING',
|
|
270
|
+
});
|
|
271
|
+
await jobStore.markBatchIssuanceStarted(id, 'worker');
|
|
272
|
+
await expireLease(receipt.batchId);
|
|
273
|
+
await jobStore.recoverInboxJobs();
|
|
274
|
+
expect(await submit(batch)).toMatchObject({
|
|
275
|
+
batchId: receipt.batchId,
|
|
276
|
+
status: 'NEEDS_RECONCILIATION',
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('does not let a late publication ack postpone a worker retry', async () => {
|
|
281
|
+
const receipt = await submit({
|
|
282
|
+
items: [{ recipient: email('ack@test.com'), credential: await signed() }],
|
|
283
|
+
});
|
|
284
|
+
const leases = await jobStore.takeInboxDispatches();
|
|
285
|
+
const id = `${receipt.batchId}:0`;
|
|
286
|
+
await jobStore.claimBatchItem(id, 'fast-worker');
|
|
287
|
+
await jobStore.finishBatchItem(id, 'fast-worker', {
|
|
288
|
+
success: false,
|
|
289
|
+
index: 0,
|
|
290
|
+
error: { code: 'INTERNAL_SERVER_ERROR', message: 'retry' },
|
|
291
|
+
});
|
|
292
|
+
await jobStore.acknowledgeInboxDispatches(leases);
|
|
293
|
+
expect((await jobStore.takeInboxDispatches()).map(lease => lease.id)).toContain(id);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('keeps overlapping item keys queued and then replays the first success', async () => {
|
|
297
|
+
const entry = {
|
|
298
|
+
recipient: email('overlap@test.com'),
|
|
299
|
+
credential: await signed(),
|
|
300
|
+
idempotencyKey: 'overlap',
|
|
301
|
+
};
|
|
302
|
+
const first = await submit({ items: [entry] });
|
|
303
|
+
const second = await submit({ items: [entry] });
|
|
304
|
+
let release!: () => void;
|
|
305
|
+
let started!: () => void;
|
|
306
|
+
const blocked = new Promise<void>(resolve => {
|
|
307
|
+
release = resolve;
|
|
308
|
+
});
|
|
309
|
+
const entered = new Promise<void>(resolve => {
|
|
310
|
+
started = resolve;
|
|
311
|
+
});
|
|
312
|
+
const original = issuance.resolveInboxCredentialInput;
|
|
313
|
+
vi.spyOn(issuance, 'resolveInboxCredentialInput').mockImplementationOnce(
|
|
314
|
+
async (...args) => {
|
|
315
|
+
started();
|
|
316
|
+
await blocked;
|
|
317
|
+
return original(...args);
|
|
318
|
+
}
|
|
319
|
+
);
|
|
320
|
+
const { processInboxQueueMessage } = await import('@helpers/inbox-queue.helpers');
|
|
321
|
+
const worker = processInboxQueueMessage(JSON.stringify({ itemId: `${first.batchId}:0` }));
|
|
322
|
+
try {
|
|
323
|
+
await entered;
|
|
324
|
+
await processInboxQueueMessage(JSON.stringify({ itemId: `${second.batchId}:0` }));
|
|
325
|
+
expect(await statusOf(second.batchId)).toMatchObject({
|
|
326
|
+
done: false,
|
|
327
|
+
summary: { pending: 1, failed: 0 },
|
|
328
|
+
});
|
|
329
|
+
} finally {
|
|
330
|
+
release();
|
|
331
|
+
await worker;
|
|
332
|
+
}
|
|
333
|
+
await processInboxQueueMessage(JSON.stringify({ itemId: `${second.batchId}:0` }));
|
|
334
|
+
expect(await statusOf(second.batchId)).toMatchObject({
|
|
335
|
+
done: true,
|
|
336
|
+
summary: { succeeded: 1, deduplicated: 1 },
|
|
337
|
+
});
|
|
338
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(1);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('counts unconfirmed outcomes once and retains correlation after payload cleanup', async () => {
|
|
342
|
+
const receipt = await submit({
|
|
343
|
+
items: [
|
|
344
|
+
{
|
|
345
|
+
recipient: email('uncertain@test.com'),
|
|
346
|
+
credential: await signed(),
|
|
347
|
+
idempotencyKey: 'uncertain',
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
recipient: email('pending@test.com'),
|
|
351
|
+
credential: await signed(),
|
|
352
|
+
idempotencyKey: 'pending',
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
});
|
|
356
|
+
await jobStore.claimBatchItem(`${receipt.batchId}:0`, 'worker');
|
|
357
|
+
await jobStore.markBatchIssuanceStarted(`${receipt.batchId}:0`, 'worker');
|
|
358
|
+
await expireLease(receipt.batchId);
|
|
359
|
+
await jobStore.recoverInboxJobs();
|
|
360
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({
|
|
361
|
+
done: false,
|
|
362
|
+
summary: { total: 2, completed: 1, pending: 1, unconfirmed: 1, failed: 0 },
|
|
363
|
+
});
|
|
364
|
+
await poll(receipt.batchId);
|
|
365
|
+
const status = await statusOf(receipt.batchId);
|
|
366
|
+
expect(status).toMatchObject({
|
|
367
|
+
done: true,
|
|
368
|
+
summary: {
|
|
369
|
+
total: 2,
|
|
370
|
+
completed: 2,
|
|
371
|
+
pending: 0,
|
|
372
|
+
unconfirmed: 1,
|
|
373
|
+
succeeded: 1,
|
|
374
|
+
failed: 0,
|
|
375
|
+
},
|
|
376
|
+
items: [
|
|
377
|
+
{
|
|
378
|
+
result: {
|
|
379
|
+
idempotencyKey: 'uncertain',
|
|
380
|
+
recipient: email('uncertain@test.com'),
|
|
381
|
+
error: { reason: 'UNCONFIRMED' },
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
{ result: { idempotencyKey: 'pending', recipient: email('pending@test.com') } },
|
|
385
|
+
],
|
|
386
|
+
});
|
|
387
|
+
expect(
|
|
388
|
+
(await jobStore.readBatchJob(receipt.batchId, 'batch-issuer')).job.payload
|
|
389
|
+
).toBeUndefined();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('rejects invalid input at admission without charging quota', async () => {
|
|
393
|
+
const response = await post({
|
|
394
|
+
items: [
|
|
395
|
+
{ recipient: email('missing@test.com') },
|
|
396
|
+
{
|
|
397
|
+
recipient: email('self@test.com'),
|
|
398
|
+
credential: await signed(),
|
|
399
|
+
configuration: { guardianEmail: 'SELF@test.com' },
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
});
|
|
403
|
+
expect(response.statusCode).toBe(400);
|
|
404
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(0);
|
|
405
|
+
const jobs = await neogma.queryRunner.run('MATCH (b:InboxBatch) RETURN b');
|
|
406
|
+
expect(jobs.records).toHaveLength(0);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('continues dispatching later chunks after a partial SQS failure', async () => {
|
|
410
|
+
const credential = await signed();
|
|
411
|
+
const receipt = await submit({
|
|
412
|
+
items: Array.from({ length: 25 }, (_, i) => ({
|
|
413
|
+
recipient: email(`partial-${i}@test.com`),
|
|
414
|
+
credential,
|
|
415
|
+
})),
|
|
416
|
+
});
|
|
417
|
+
const originalSend = SQSClient.prototype.send;
|
|
418
|
+
const send = vi.spyOn(SQSClient.prototype, 'send').mockImplementationOnce(async function (
|
|
419
|
+
this: SQSClient,
|
|
420
|
+
command
|
|
421
|
+
) {
|
|
422
|
+
if (!(command instanceof SendMessageBatchCommand))
|
|
423
|
+
throw new Error('Expected batch publication');
|
|
424
|
+
const [failed, ...entries] = command.input.Entries!;
|
|
425
|
+
const response = await originalSend.call(
|
|
426
|
+
this,
|
|
427
|
+
new SendMessageBatchCommand({ ...command.input, Entries: entries })
|
|
428
|
+
);
|
|
429
|
+
return {
|
|
430
|
+
...response,
|
|
431
|
+
Failed: [{ Id: failed!.Id, Code: 'ServiceUnavailable', SenderFault: false }],
|
|
432
|
+
};
|
|
433
|
+
});
|
|
434
|
+
await expect(dispatchInboxJobs()).rejects.toThrow('partially failed');
|
|
435
|
+
expect(send).toHaveBeenCalledTimes(3);
|
|
436
|
+
send.mockRestore();
|
|
437
|
+
for (let i = 0; i < 3; i++) await consumeInboxQueueOnce(0);
|
|
438
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({
|
|
439
|
+
summary: { succeeded: 24, pending: 1 },
|
|
440
|
+
});
|
|
441
|
+
await neogma.queryRunner.run(
|
|
442
|
+
'MATCH (i:InboxBatchItem {batchId: $id}) WHERE i.state = "QUEUED" SET i.dispatchAt = 0',
|
|
443
|
+
{ id: receipt.batchId }
|
|
444
|
+
);
|
|
445
|
+
expect((await poll(receipt.batchId)).summary.succeeded).toBe(25);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('publishes queued work even when recovery fails', async () => {
|
|
449
|
+
const receipt = await submit({
|
|
450
|
+
items: [{ recipient: email('maintenance@test.com'), credential: await signed() }],
|
|
451
|
+
});
|
|
452
|
+
const recover = vi
|
|
453
|
+
.spyOn(jobStore, 'recoverInboxJobs')
|
|
454
|
+
.mockRejectedValueOnce(new Error('Maintenance unavailable'));
|
|
455
|
+
const deadline = Date.now() + 10_000;
|
|
456
|
+
await expect(dispatchInboxJobs(deadline)).rejects.toThrow('Maintenance unavailable');
|
|
457
|
+
expect(recover).toHaveBeenCalledWith(deadline);
|
|
458
|
+
await consumeInboxQueueOnce(0);
|
|
459
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({
|
|
460
|
+
status: 'COMPLETED',
|
|
461
|
+
summary: { succeeded: 1 },
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('tolerates duplicate SQS deliveries and concurrent consumers without duplicating unkeyed issuance', async () => {
|
|
466
|
+
const receipt = await submit({
|
|
467
|
+
items: [{ recipient: email('duplicate@test.com'), credential: await signed() }],
|
|
468
|
+
});
|
|
469
|
+
const client = new SQSClient({
|
|
470
|
+
endpoint: process.env.INBOX_QUEUE_ENDPOINT,
|
|
471
|
+
region: 'us-east-1',
|
|
472
|
+
credentials: { accessKeyId: 'local', secretAccessKey: 'local' },
|
|
473
|
+
});
|
|
474
|
+
try {
|
|
475
|
+
await Promise.all(
|
|
476
|
+
Array.from({ length: 3 }, () =>
|
|
477
|
+
client.send(
|
|
478
|
+
new SendMessageCommand({
|
|
479
|
+
QueueUrl: process.env.INBOX_QUEUE_URL,
|
|
480
|
+
MessageBody: JSON.stringify({ itemId: `${receipt.batchId}:0` }),
|
|
481
|
+
})
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
);
|
|
485
|
+
await dispatchInboxJobs();
|
|
486
|
+
await Promise.all([consumeInboxQueueOnce(0), consumeInboxQueueOnce(0)]);
|
|
487
|
+
expect((await poll(receipt.batchId)).summary.succeeded).toBe(1);
|
|
488
|
+
expect(sendSpy).toHaveBeenCalledTimes(1);
|
|
489
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(1);
|
|
490
|
+
} finally {
|
|
491
|
+
client.destroy();
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('keeps an accepted batch durable when publication fails, then dispatches it after recovery', async () => {
|
|
496
|
+
const receipt = await submit({
|
|
497
|
+
items: [{ recipient: email('outbox@test.com'), credential: await signed() }],
|
|
498
|
+
});
|
|
499
|
+
const send = vi
|
|
500
|
+
.spyOn(SQSClient.prototype, 'send')
|
|
501
|
+
.mockRejectedValueOnce(new Error('SQS unavailable'));
|
|
502
|
+
await expect(dispatchInboxJobs()).rejects.toThrow('SQS unavailable');
|
|
503
|
+
send.mockRestore();
|
|
504
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({ status: 'QUEUED' });
|
|
505
|
+
await neogma.queryRunner.run(
|
|
506
|
+
'MATCH (i:InboxBatchItem {batchId: $id}) SET i.dispatchAt = 0',
|
|
507
|
+
{ id: receipt.batchId }
|
|
508
|
+
);
|
|
509
|
+
expect((await poll(receipt.batchId)).summary.succeeded).toBe(1);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('recovers an expired preparation lease and rejects the old owner before issuance', async () => {
|
|
513
|
+
const receipt = await submit({
|
|
514
|
+
items: [
|
|
515
|
+
{
|
|
516
|
+
recipient: email('restart@test.com'),
|
|
517
|
+
credential: await signed(),
|
|
518
|
+
idempotencyKey: 'restart',
|
|
519
|
+
},
|
|
520
|
+
],
|
|
521
|
+
});
|
|
522
|
+
const id = `${receipt.batchId}:0`;
|
|
523
|
+
const claimed = await jobStore.claimBatchItem(id, 'old-worker');
|
|
524
|
+
const replayStore = jobStore.batchReplayStore(id, 'old-worker', claimed!.item.replayKey);
|
|
525
|
+
expect(
|
|
526
|
+
await replayStore.setIfAbsent(
|
|
527
|
+
'',
|
|
528
|
+
JSON.stringify({ state: 'processing', requestHash: 'old' }),
|
|
529
|
+
86400
|
|
530
|
+
)
|
|
531
|
+
).toBe('OK');
|
|
532
|
+
await expireLease(receipt.batchId);
|
|
533
|
+
await jobStore.recoverInboxJobs();
|
|
534
|
+
await expect(jobStore.markBatchIssuanceStarted(id, 'old-worker')).rejects.toThrow(
|
|
535
|
+
'lease lost'
|
|
536
|
+
);
|
|
537
|
+
expect((await poll(receipt.batchId)).summary.succeeded).toBe(1);
|
|
538
|
+
expect(sendSpy).toHaveBeenCalledTimes(1);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('marks an interrupted issuance unconfirmed and retains its key beyond the replay window', async () => {
|
|
542
|
+
const entry = {
|
|
543
|
+
recipient: email('uncertain@test.com'),
|
|
544
|
+
credential: await signed(),
|
|
545
|
+
idempotencyKey: 'uncertain',
|
|
546
|
+
};
|
|
547
|
+
const receipt = await submit({ items: [entry] });
|
|
548
|
+
const id = `${receipt.batchId}:0`;
|
|
549
|
+
const claimed = await jobStore.claimBatchItem(id, 'lost-worker');
|
|
550
|
+
await jobStore
|
|
551
|
+
.batchReplayStore(id, 'lost-worker', claimed!.item.replayKey)
|
|
552
|
+
.setIfAbsent(
|
|
553
|
+
'',
|
|
554
|
+
JSON.stringify({ state: 'processing', requestHash: 'uncertain' }),
|
|
555
|
+
86400
|
|
556
|
+
);
|
|
557
|
+
await jobStore.markBatchIssuanceStarted(id, 'lost-worker');
|
|
558
|
+
await expireLease(receipt.batchId);
|
|
559
|
+
await jobStore.recoverInboxJobs();
|
|
560
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({
|
|
561
|
+
status: 'NEEDS_RECONCILIATION',
|
|
562
|
+
summary: { unconfirmed: 1 },
|
|
563
|
+
});
|
|
564
|
+
expect((await replay('uncertain')).expiresAt).toBeUndefined();
|
|
565
|
+
expect((await issue({ items: [entry] })).results[0]).toMatchObject({
|
|
566
|
+
success: false,
|
|
567
|
+
error: { code: 'CONFLICT' },
|
|
568
|
+
});
|
|
569
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('recovers a durable success after the worker loses its completion write', async () => {
|
|
573
|
+
const receipt = await submit({
|
|
574
|
+
items: [{ recipient: email('commit@test.com'), credential: await signed() }],
|
|
575
|
+
});
|
|
576
|
+
vi.spyOn(jobStore, 'finishBatchItem').mockRejectedValueOnce(
|
|
577
|
+
new Error('Lost completion write')
|
|
578
|
+
);
|
|
579
|
+
await dispatchInboxJobs();
|
|
580
|
+
await expect(consumeInboxQueueOnce(0)).rejects.toThrow('Lost completion write');
|
|
581
|
+
await expireLease(receipt.batchId);
|
|
582
|
+
await jobStore.recoverInboxJobs();
|
|
583
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({
|
|
584
|
+
status: 'COMPLETED',
|
|
585
|
+
summary: { succeeded: 1 },
|
|
586
|
+
});
|
|
587
|
+
expect((await statusOf(receipt.batchId)).items[0]?.result?.idempotencyKey).toBeUndefined();
|
|
588
|
+
expect(sendSpy).toHaveBeenCalledTimes(1);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('exposes dead-letter exhaustion and cleans up completed jobs after 30 days', async () => {
|
|
592
|
+
const receipt = await submit({
|
|
593
|
+
items: [{ recipient: email('dead@test.com'), credential: await signed() }],
|
|
594
|
+
});
|
|
595
|
+
const client = new SQSClient({
|
|
596
|
+
endpoint: process.env.INBOX_QUEUE_ENDPOINT,
|
|
597
|
+
region: 'us-east-1',
|
|
598
|
+
credentials: { accessKeyId: 'local', secretAccessKey: 'local' },
|
|
599
|
+
});
|
|
600
|
+
try {
|
|
601
|
+
await client.send(
|
|
602
|
+
new SendMessageCommand({
|
|
603
|
+
QueueUrl: process.env.INBOX_DEAD_LETTER_QUEUE_URL,
|
|
604
|
+
MessageBody: JSON.stringify({ itemId: `${receipt.batchId}:0` }),
|
|
605
|
+
})
|
|
606
|
+
);
|
|
607
|
+
await consumeInboxDeadLettersOnce();
|
|
608
|
+
} finally {
|
|
609
|
+
client.destroy();
|
|
610
|
+
}
|
|
611
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({
|
|
612
|
+
status: 'COMPLETED',
|
|
613
|
+
summary: { failed: 1 },
|
|
614
|
+
});
|
|
615
|
+
await jobStore.recoverInboxJobs();
|
|
616
|
+
await neogma.queryRunner.run('MATCH (b:InboxBatch {id: $id}) SET b.completedAt = $old', {
|
|
617
|
+
id: receipt.batchId,
|
|
618
|
+
old: Date.now() - 31 * jobStore.DAY,
|
|
619
|
+
});
|
|
620
|
+
await jobStore.recoverInboxJobs();
|
|
621
|
+
await expect(statusOf(receipt.batchId)).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('fails closed without queue configuration and leaves no accepted job', async () => {
|
|
625
|
+
vi.stubEnv('INBOX_QUEUE_URL', '');
|
|
626
|
+
const credential = await signed();
|
|
627
|
+
// The programmatic route also rejects oversized input before checking queue setup.
|
|
628
|
+
await expect(
|
|
629
|
+
issuer.clients.fullAuth.inbox.issueBatch({
|
|
630
|
+
items: [{ recipient: email('too-large@test.com'), credential }],
|
|
631
|
+
configuration: { templateData: { padding: 'x'.repeat(4 * 1024 * 1024) } },
|
|
632
|
+
})
|
|
633
|
+
).rejects.toMatchObject({ code: 'PAYLOAD_TOO_LARGE' });
|
|
634
|
+
const response = await post({
|
|
635
|
+
items: [{ recipient: email('offline@test.com'), credential }],
|
|
636
|
+
});
|
|
637
|
+
expect(response.statusCode).toBe(500);
|
|
638
|
+
const rows = await neogma.queryRunner.run('MATCH (b:InboxBatch) RETURN count(b) AS count');
|
|
639
|
+
expect(Number(rows.records[0]!.get('count'))).toBe(0);
|
|
640
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it('atomically admits concurrent batches without overspending the hourly quota', async () => {
|
|
644
|
+
vi.stubEnv('INBOX_BATCH_ITEMS_PER_HOUR', '2');
|
|
645
|
+
const credential = await signed();
|
|
646
|
+
const responses = await Promise.all(
|
|
647
|
+
Array.from({ length: 5 }, (_, i) =>
|
|
648
|
+
post({ items: [{ recipient: email(`quota-${i}@test.com`), credential }] })
|
|
649
|
+
)
|
|
650
|
+
);
|
|
651
|
+
expect(responses.filter(response => response.statusCode === 202)).toHaveLength(2);
|
|
652
|
+
expect(responses.filter(response => response.statusCode === 429)).toHaveLength(3);
|
|
653
|
+
const quota = await neogma.queryRunner.run(
|
|
654
|
+
'MATCH (q:InboxBatchIssuer {id: "batch-issuer"}) RETURN q.used AS used'
|
|
655
|
+
);
|
|
656
|
+
expect(Number(quota.records[0]!.get('used'))).toBe(2);
|
|
657
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('returns three pending results in input order and defers signing until claim', async () => {
|
|
661
|
+
// Use an unsigned credential and a configured signing authority to exercise the normal
|
|
662
|
+
// inbox flow: unknown recipients receive an unsigned credential in escrow, and it is
|
|
663
|
+
// signed only after the recipient claims it.
|
|
664
|
+
const sign = vi.spyOn(signing, 'issueCredentialWithSigningAuthority');
|
|
665
|
+
const credential = await unsigned();
|
|
666
|
+
const recipients = ['one@test.com', 'two@test.com', 'three@test.com'].map(email);
|
|
667
|
+
|
|
668
|
+
// Submit shared configuration once. The issue test helper asserts HTTP 202, sends real
|
|
669
|
+
// SQS messages through the worker, and polls until each item has a durable result.
|
|
670
|
+
// Processing can finish out of order; polling must retain the original item order.
|
|
671
|
+
const batch = await issue({
|
|
672
|
+
configuration: { signingAuthority },
|
|
673
|
+
items: recipients.map(recipient => ({ recipient, credential })),
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// A successful batch reports both a compact aggregate and one outcome per submitted item.
|
|
677
|
+
// Partial failures would still appear in `results`; this all-success case establishes the
|
|
678
|
+
// expected baseline for a three-recipient batch.
|
|
679
|
+
expect(batch.summary).toEqual({ total: 3, succeeded: 3, failed: 0, deduplicated: 0 });
|
|
680
|
+
expect(batch.results.map(r => r.index)).toEqual([0, 1, 2]);
|
|
681
|
+
for (const [index, result] of batch.results.entries()) {
|
|
682
|
+
// Each result is correlated to its original input index and has the same pending
|
|
683
|
+
// inbox semantics as a single issuance: a claim URL is created for the recipient.
|
|
684
|
+
expect(result).toMatchObject({
|
|
685
|
+
success: true,
|
|
686
|
+
status: 'PENDING',
|
|
687
|
+
recipient: recipients[index],
|
|
688
|
+
claimUrl: expect.any(String),
|
|
689
|
+
});
|
|
690
|
+
if (!result.success) throw new Error('Expected successful issuance');
|
|
691
|
+
|
|
692
|
+
// The stored copy remains unsigned while it waits in the Universal Inbox. This proves
|
|
693
|
+
// the batch orchestrator delegated to the existing deferred-signing issuance path.
|
|
694
|
+
expect((await stored(result.issuanceId)).isSigned).toBe(false);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Issuing the batch sends one notification per recipient but must not sign all three
|
|
698
|
+
// credentials eagerly. Signing is intentionally delayed until an individual claim.
|
|
699
|
+
expect(sign).not.toHaveBeenCalled();
|
|
700
|
+
expect(sendSpy).toHaveBeenCalledTimes(3);
|
|
701
|
+
|
|
702
|
+
// Claiming only the first result signs only that credential. This demonstrates that batch
|
|
703
|
+
// issuance creates independent inbox records rather than one shared batch-level claim.
|
|
704
|
+
const first = batch.results[0]!;
|
|
705
|
+
if (!first.success) throw new Error('Expected successful issuance');
|
|
706
|
+
expect((await claim(first.claimUrl!))[0]?.proof).toBeDefined();
|
|
707
|
+
expect(sign).toHaveBeenCalledTimes(1);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('stores a pre-signed credential unchanged and preserves its proof through claim', async () => {
|
|
711
|
+
const credential = await signed();
|
|
712
|
+
const sign = vi.spyOn(signing, 'issueCredentialWithSigningAuthority');
|
|
713
|
+
const {
|
|
714
|
+
results: [result],
|
|
715
|
+
} = await issue({ items: [{ recipient: email('signed@test.com'), credential }] });
|
|
716
|
+
if (!result?.success) throw new Error('Expected successful issuance');
|
|
717
|
+
expect(
|
|
718
|
+
JSON.parse(await decryptInboxCredential((await stored(result.issuanceId)).credential))
|
|
719
|
+
).toEqual(credential);
|
|
720
|
+
expect(await claim(result.claimUrl!)).toEqual([credential]);
|
|
721
|
+
expect(sign).not.toHaveBeenCalled();
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('retries a transient signing failure before delivery without requiring reconciliation', async () => {
|
|
725
|
+
const contact = await createContactMethod({
|
|
726
|
+
...email('retry-signing@test.com'),
|
|
727
|
+
isVerified: true,
|
|
728
|
+
});
|
|
729
|
+
await createProfileContactMethodRelationship('batch-holder', contact.id);
|
|
730
|
+
const sign = vi
|
|
731
|
+
.spyOn(signing, 'issueCredentialWithSigningAuthority')
|
|
732
|
+
.mockRejectedValueOnce(new Error('Signing authority timeout'));
|
|
733
|
+
const receipt = await submit({
|
|
734
|
+
configuration: { signingAuthority },
|
|
735
|
+
items: [
|
|
736
|
+
{
|
|
737
|
+
recipient: email('retry-signing@test.com'),
|
|
738
|
+
credential: await unsigned(),
|
|
739
|
+
idempotencyKey: 'retry-signing',
|
|
740
|
+
},
|
|
741
|
+
],
|
|
742
|
+
});
|
|
743
|
+
await dispatchInboxJobs();
|
|
744
|
+
await consumeInboxQueueOnce(0);
|
|
745
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({
|
|
746
|
+
status: 'QUEUED',
|
|
747
|
+
summary: { pending: 1, unconfirmed: 0 },
|
|
748
|
+
});
|
|
749
|
+
expect(await replay('retry-signing')).toBeUndefined();
|
|
750
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(0);
|
|
751
|
+
expect(
|
|
752
|
+
(await jobStore.readBatchJob(receipt.batchId, 'batch-issuer')).job.payload
|
|
753
|
+
).toBeDefined();
|
|
754
|
+
expect((await poll(receipt.batchId)).summary.succeeded).toBe(1);
|
|
755
|
+
expect(sign).toHaveBeenCalledTimes(2);
|
|
756
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(1);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('blocks a stale worker after signing and before delivery', async () => {
|
|
760
|
+
const contact = await createContactMethod({
|
|
761
|
+
...email('stale-signing@test.com'),
|
|
762
|
+
isVerified: true,
|
|
763
|
+
});
|
|
764
|
+
await createProfileContactMethodRelationship('batch-holder', contact.id);
|
|
765
|
+
const receipt = await submit({
|
|
766
|
+
configuration: { signingAuthority },
|
|
767
|
+
items: [
|
|
768
|
+
{
|
|
769
|
+
recipient: email('stale-signing@test.com'),
|
|
770
|
+
credential: await unsigned(),
|
|
771
|
+
idempotencyKey: 'stale-signing',
|
|
772
|
+
},
|
|
773
|
+
],
|
|
774
|
+
});
|
|
775
|
+
const originalSign = signing.issueCredentialWithSigningAuthority;
|
|
776
|
+
vi.spyOn(signing, 'issueCredentialWithSigningAuthority').mockImplementationOnce(
|
|
777
|
+
async (...args) => {
|
|
778
|
+
const result = await originalSign(...args);
|
|
779
|
+
await expireLease(receipt.batchId);
|
|
780
|
+
await jobStore.recoverInboxJobs();
|
|
781
|
+
return result;
|
|
782
|
+
}
|
|
783
|
+
);
|
|
784
|
+
await dispatchInboxJobs();
|
|
785
|
+
await expect(consumeInboxQueueOnce(0)).rejects.toThrow('lease lost');
|
|
786
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(0);
|
|
787
|
+
expect((await poll(receipt.batchId)).summary.succeeded).toBe(1);
|
|
788
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(1);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it.each(['finish', 'recovery', 'dead-letter'] as const)(
|
|
792
|
+
'drops settled payloads and prunes uncertain jobs while preserving reservations via %s',
|
|
793
|
+
async path => {
|
|
794
|
+
const entry = {
|
|
795
|
+
recipient: email('retention@test.com'),
|
|
796
|
+
credential: await signed(),
|
|
797
|
+
idempotencyKey: 'retention',
|
|
798
|
+
};
|
|
799
|
+
const receipt = await submit({
|
|
800
|
+
items: [
|
|
801
|
+
entry,
|
|
802
|
+
{ ...entry, idempotencyKey: 'retention-second' },
|
|
803
|
+
{
|
|
804
|
+
recipient: email('unkeyed-retention@test.com'),
|
|
805
|
+
credential: entry.credential,
|
|
806
|
+
},
|
|
807
|
+
],
|
|
808
|
+
});
|
|
809
|
+
const internalId = `${receipt.batchId}:2`;
|
|
810
|
+
const internal = await jobStore.claimBatchItem(internalId, 'worker');
|
|
811
|
+
await jobStore
|
|
812
|
+
.batchReplayStore(internalId, 'worker', internal!.item.replayKey)
|
|
813
|
+
.setIfAbsent(
|
|
814
|
+
'',
|
|
815
|
+
JSON.stringify({ state: 'processing', requestHash: 'internal' }),
|
|
816
|
+
86400
|
|
817
|
+
);
|
|
818
|
+
await jobStore.markBatchIssuanceStarted(internalId, 'worker');
|
|
819
|
+
await jobStore.finishBatchItem(internalId, 'worker', {
|
|
820
|
+
success: false,
|
|
821
|
+
index: 2,
|
|
822
|
+
error: { code: 'CONFLICT', message: 'Delivery uncertain' },
|
|
823
|
+
});
|
|
824
|
+
const id = `${receipt.batchId}:0`;
|
|
825
|
+
const claimed = await jobStore.claimBatchItem(id, 'worker');
|
|
826
|
+
await jobStore
|
|
827
|
+
.batchReplayStore(id, 'worker', claimed!.item.replayKey)
|
|
828
|
+
.setIfAbsent(
|
|
829
|
+
'',
|
|
830
|
+
JSON.stringify({ state: 'processing', requestHash: 'retention' }),
|
|
831
|
+
86400
|
|
832
|
+
);
|
|
833
|
+
await jobStore.markBatchIssuanceStarted(id, 'worker');
|
|
834
|
+
if (path === 'finish') {
|
|
835
|
+
await jobStore.finishBatchItem(id, 'worker', {
|
|
836
|
+
success: false,
|
|
837
|
+
index: 0,
|
|
838
|
+
error: { code: 'CONFLICT', message: 'Delivery uncertain' },
|
|
839
|
+
});
|
|
840
|
+
} else {
|
|
841
|
+
await expireLease(receipt.batchId);
|
|
842
|
+
if (path === 'recovery') await jobStore.recoverInboxJobs();
|
|
843
|
+
else await jobStore.deadLetterBatchItem(id);
|
|
844
|
+
}
|
|
845
|
+
// The second item can still progress, so the payload must remain available.
|
|
846
|
+
expect(
|
|
847
|
+
(await jobStore.readBatchJob(receipt.batchId, 'batch-issuer')).job.payload
|
|
848
|
+
).toBeDefined();
|
|
849
|
+
await jobStore.deadLetterBatchItem(`${receipt.batchId}:1`);
|
|
850
|
+
const { job } = await jobStore.readBatchJob(receipt.batchId, 'batch-issuer');
|
|
851
|
+
expect(job.payload).toBeUndefined();
|
|
852
|
+
expect(job.completedAt).toBeDefined();
|
|
853
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({
|
|
854
|
+
status: 'NEEDS_RECONCILIATION',
|
|
855
|
+
summary: { pending: 0, unconfirmed: 2 },
|
|
856
|
+
});
|
|
857
|
+
const internalReplay = async () =>
|
|
858
|
+
neogma.queryRunner.run('MATCH (r:InboxBatchReplay {id: $id}) RETURN r', {
|
|
859
|
+
id: internal!.item.replayKey,
|
|
860
|
+
});
|
|
861
|
+
await jobStore.recoverInboxJobs();
|
|
862
|
+
expect((await internalReplay()).records).toHaveLength(1);
|
|
863
|
+
await neogma.queryRunner.run(
|
|
864
|
+
'MATCH (b:InboxBatch {id: $id}) SET b.completedAt = $old',
|
|
865
|
+
{ id: receipt.batchId, old: Date.now() - 31 * jobStore.DAY }
|
|
866
|
+
);
|
|
867
|
+
await jobStore.recoverInboxJobs();
|
|
868
|
+
await expect(statusOf(receipt.batchId)).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
869
|
+
expect((await internalReplay()).records).toHaveLength(0);
|
|
870
|
+
expect((await replay('retention')).expiresAt).toBeUndefined();
|
|
871
|
+
expect((await issue({ items: [entry] })).results[0]).toMatchObject({
|
|
872
|
+
success: false,
|
|
873
|
+
error: { code: 'CONFLICT' },
|
|
874
|
+
});
|
|
875
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
876
|
+
}
|
|
877
|
+
);
|
|
878
|
+
|
|
879
|
+
it('bounds orphaned internal replay cleanup and preserves client reservations', async () => {
|
|
880
|
+
await neogma.queryRunner.run(
|
|
881
|
+
`UNWIND range(0, 1000) AS index
|
|
882
|
+
CREATE (:InboxBatchReplay {id: 'internal:orphan-' + toString(index), itemId: 'missing'})`
|
|
883
|
+
);
|
|
884
|
+
await neogma.queryRunner.run(
|
|
885
|
+
`CREATE (:InboxBatchReplay {id: 'client:orphan', itemId: 'missing'})`
|
|
886
|
+
);
|
|
887
|
+
const remaining = async () => {
|
|
888
|
+
const rows = await neogma.queryRunner.run(
|
|
889
|
+
'MATCH (r:InboxBatchReplay) RETURN r.id AS id'
|
|
890
|
+
);
|
|
891
|
+
return rows.records.map(row => row.get('id') as string);
|
|
892
|
+
};
|
|
893
|
+
await jobStore.recoverInboxJobs();
|
|
894
|
+
const ids = await remaining();
|
|
895
|
+
expect(ids.filter(id => id.startsWith('internal:'))).toHaveLength(1);
|
|
896
|
+
expect(ids).toContain('client:orphan');
|
|
897
|
+
await jobStore.recoverInboxJobs();
|
|
898
|
+
expect(await remaining()).toEqual(['client:orphan']);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it('defers all cleanup with five seconds remaining and resumes with more budget', async () => {
|
|
902
|
+
const receipt = await submit({
|
|
903
|
+
items: [{ recipient: email('cleanup-budget@test.com'), credential: await signed() }],
|
|
904
|
+
});
|
|
905
|
+
await jobStore.deadLetterBatchItem(`${receipt.batchId}:0`);
|
|
906
|
+
const now = Date.now();
|
|
907
|
+
await neogma.queryRunner.run('MATCH (b:InboxBatch {id: $id}) SET b.completedAt = $old', {
|
|
908
|
+
id: receipt.batchId,
|
|
909
|
+
old: now - 31 * jobStore.DAY,
|
|
910
|
+
});
|
|
911
|
+
await neogma.queryRunner.run(
|
|
912
|
+
`CREATE (:InboxBatchReplay {id: 'client:expired', expiresAt: $expired}),
|
|
913
|
+
(:InboxBatchReplay {id: 'internal:orphan-budget', itemId: 'missing'})`,
|
|
914
|
+
{ expired: now - 1 }
|
|
915
|
+
);
|
|
916
|
+
const replayCount = async () => {
|
|
917
|
+
const rows = await neogma.queryRunner.run(
|
|
918
|
+
'MATCH (r:InboxBatchReplay) RETURN count(r) AS count'
|
|
919
|
+
);
|
|
920
|
+
return Number(rows.records[0]!.get('count'));
|
|
921
|
+
};
|
|
922
|
+
const clock = vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
923
|
+
try {
|
|
924
|
+
await jobStore.recoverInboxJobs(now + 5_000);
|
|
925
|
+
expect(await statusOf(receipt.batchId)).toMatchObject({ status: 'COMPLETED' });
|
|
926
|
+
expect(await replayCount()).toBe(2);
|
|
927
|
+
await jobStore.recoverInboxJobs(now + 5_001);
|
|
928
|
+
await expect(statusOf(receipt.batchId)).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
929
|
+
expect(await replayCount()).toBe(0);
|
|
930
|
+
} finally {
|
|
931
|
+
clock.mockRestore();
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it('signs and auto-delivers only the verified known recipient', async () => {
|
|
936
|
+
const contact = await createContactMethod({ ...email('known@test.com'), isVerified: true });
|
|
937
|
+
await createProfileContactMethodRelationship('batch-holder', contact.id);
|
|
938
|
+
const sign = vi.spyOn(signing, 'issueCredentialWithSigningAuthority');
|
|
939
|
+
const credential = await unsigned();
|
|
940
|
+
const batch = await issue({
|
|
941
|
+
configuration: { signingAuthority },
|
|
942
|
+
items: [
|
|
943
|
+
{ recipient: email('known@test.com'), credential },
|
|
944
|
+
{ recipient: email('unknown@test.com'), credential },
|
|
945
|
+
],
|
|
946
|
+
});
|
|
947
|
+
expect(batch.results[0]).toMatchObject({
|
|
948
|
+
success: true,
|
|
949
|
+
status: 'ISSUED',
|
|
950
|
+
recipientDid: expect.any(String),
|
|
951
|
+
});
|
|
952
|
+
expect(batch.results[1]).toMatchObject({ success: true, status: 'PENDING' });
|
|
953
|
+
expect(sign).toHaveBeenCalledTimes(1);
|
|
954
|
+
expect(sendSpy).toHaveBeenCalledTimes(1);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it('renders template data independently for each recipient', async () => {
|
|
958
|
+
const template = structuredClone(testUnsignedBoost);
|
|
959
|
+
template.name = '{{student.name}}';
|
|
960
|
+
const templateUri = await issuer.clients.fullAuth.boost.createBoost({
|
|
961
|
+
credential: template,
|
|
962
|
+
name: 'Batch template',
|
|
963
|
+
category: 'Achievement',
|
|
964
|
+
});
|
|
965
|
+
const batch = await issue({
|
|
966
|
+
configuration: { signingAuthority, templateData: { student: { course: 'Math' } } },
|
|
967
|
+
items: ['Ada', 'Grace'].map(name => ({
|
|
968
|
+
recipient: email(`${name}@test.com`),
|
|
969
|
+
templateUri,
|
|
970
|
+
configuration: { templateData: { student: { name } } },
|
|
971
|
+
})),
|
|
972
|
+
});
|
|
973
|
+
expect(batch.summary.failed).toBe(0);
|
|
974
|
+
const names = [];
|
|
975
|
+
for (const result of batch.results) {
|
|
976
|
+
if (!result.success) throw new Error('Expected successful issuance');
|
|
977
|
+
names.push(
|
|
978
|
+
JSON.parse(
|
|
979
|
+
await decryptInboxCredential((await stored(result.issuanceId)).credential)
|
|
980
|
+
).name
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
expect(names).toEqual(['Ada', 'Grace']);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
it('returns HTTP 202 then exposes partial success and passes tenant branding to delivery', async () => {
|
|
987
|
+
const response = await post({
|
|
988
|
+
items: [
|
|
989
|
+
{ recipient: email('good@test.com'), credential: await signed() },
|
|
990
|
+
{ recipient: email('bad@test.com'), templateUri: 'invalid-template' },
|
|
991
|
+
],
|
|
992
|
+
});
|
|
993
|
+
expect(response.statusCode, response.body).toBe(202);
|
|
994
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
995
|
+
expect(await poll(response.json().batchId)).toMatchObject({
|
|
996
|
+
summary: { total: 2, succeeded: 1, failed: 1 },
|
|
997
|
+
results: [
|
|
998
|
+
{ success: true, index: 0 },
|
|
999
|
+
{ success: false, index: 1, error: { code: 'BAD_REQUEST' } },
|
|
1000
|
+
],
|
|
1001
|
+
});
|
|
1002
|
+
expect(sendSpy.mock.calls[0]?.[0]?.branding).toMatchObject({ brandName: 'ScoutPass' });
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it('replays keys without new records, claim emails or webhook queue entries', async () => {
|
|
1006
|
+
const credential = await signed();
|
|
1007
|
+
const batch = {
|
|
1008
|
+
configuration: { webhookUrl: 'https://issuer.test/events' },
|
|
1009
|
+
items: [0, 1, 2].map(i => ({
|
|
1010
|
+
recipient: email(`replay${i}@test.com`),
|
|
1011
|
+
credential,
|
|
1012
|
+
idempotencyKey: `replay-${i}`,
|
|
1013
|
+
})),
|
|
1014
|
+
};
|
|
1015
|
+
const first = await issue(batch);
|
|
1016
|
+
const emails = sendSpy.mock.calls.length;
|
|
1017
|
+
const webhooks = addNotificationToQueueSpy.mock.calls.length;
|
|
1018
|
+
const records = await InboxCredential.findMany({ where: {} });
|
|
1019
|
+
const second = await issue({ ...batch, items: [...batch.items].reverse() });
|
|
1020
|
+
expect(second.summary).toEqual({ total: 3, succeeded: 3, failed: 0, deduplicated: 3 });
|
|
1021
|
+
expect(second.results).toEqual(
|
|
1022
|
+
[...first.results].reverse().map((r, index) => ({ ...r, index, deduplicated: true }))
|
|
1023
|
+
);
|
|
1024
|
+
expect(records).toHaveLength(3);
|
|
1025
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(records.length);
|
|
1026
|
+
expect(sendSpy).toHaveBeenCalledTimes(emails);
|
|
1027
|
+
expect(addNotificationToQueueSpy).toHaveBeenCalledTimes(webhooks);
|
|
1028
|
+
const ttl = (Number((await replay('replay-0')).expiresAt) - Date.now()) / 1000;
|
|
1029
|
+
expect(ttl).toBeGreaterThan(86300);
|
|
1030
|
+
expect(ttl).toBeLessThanOrEqual(86400);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it('does not consume an idempotency key on failure', async () => {
|
|
1034
|
+
const item = { recipient: email('retry@test.com'), idempotencyKey: 'retry' };
|
|
1035
|
+
expect((await post({ items: [item] })).statusCode).toBe(400);
|
|
1036
|
+
expect(await replay('retry')).toBeUndefined();
|
|
1037
|
+
const result = (await issue({ items: [{ ...item, credential: await signed() }] }))
|
|
1038
|
+
.results[0];
|
|
1039
|
+
expect(result).toMatchObject({ success: true });
|
|
1040
|
+
expect(result).not.toHaveProperty('deduplicated');
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
it('deep-merges batch configuration and enqueues webhooks with per-item overrides', async () => {
|
|
1044
|
+
const credential = await unsigned();
|
|
1045
|
+
const batch = await issue({
|
|
1046
|
+
configuration: {
|
|
1047
|
+
signingAuthority,
|
|
1048
|
+
webhookUrl: 'https://issuer.test/default',
|
|
1049
|
+
delivery: {
|
|
1050
|
+
suppress: true,
|
|
1051
|
+
template: {
|
|
1052
|
+
model: {
|
|
1053
|
+
issuer: {
|
|
1054
|
+
name: 'Batch issuer',
|
|
1055
|
+
logoUrl: 'https://issuer.test/logo.png',
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
items: [
|
|
1062
|
+
{
|
|
1063
|
+
recipient: email('inherit@test.com'),
|
|
1064
|
+
credential,
|
|
1065
|
+
configuration: {
|
|
1066
|
+
delivery: { template: { model: { recipient: { name: 'Ada' } } } },
|
|
1067
|
+
},
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
recipient: email('override@test.com'),
|
|
1071
|
+
credential,
|
|
1072
|
+
configuration: {
|
|
1073
|
+
signingAuthority: {
|
|
1074
|
+
endpoint: 'https://override.test/sign',
|
|
1075
|
+
name: 'override-sa',
|
|
1076
|
+
},
|
|
1077
|
+
webhookUrl: 'https://issuer.test/override',
|
|
1078
|
+
delivery: { suppress: false },
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
],
|
|
1082
|
+
});
|
|
1083
|
+
expect(batch.summary.failed).toBe(0);
|
|
1084
|
+
expect(sendSpy).toHaveBeenCalledTimes(1);
|
|
1085
|
+
expect(sendSpy.mock.calls[0]?.[0]).toMatchObject({
|
|
1086
|
+
templateModel: { issuer: { name: 'Batch issuer' } },
|
|
1087
|
+
});
|
|
1088
|
+
for (const [index, result] of batch.results.entries()) {
|
|
1089
|
+
if (!result.success) throw new Error('Expected successful issuance');
|
|
1090
|
+
expect(await stored(result.issuanceId)).toMatchObject({
|
|
1091
|
+
signingAuthority: index
|
|
1092
|
+
? { endpoint: 'https://override.test/sign', name: 'override-sa' }
|
|
1093
|
+
: signingAuthority,
|
|
1094
|
+
webhookUrl: `https://issuer.test/${index ? 'override' : 'default'}`,
|
|
1095
|
+
});
|
|
1096
|
+
expect(addNotificationToQueueSpy).toHaveBeenCalledWith(
|
|
1097
|
+
expect.objectContaining({
|
|
1098
|
+
type: 'ISSUANCE_DELIVERED',
|
|
1099
|
+
webhookUrl: `https://issuer.test/${index ? 'override' : 'default'}`,
|
|
1100
|
+
})
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it('rejects an over-quota batch before processing any item', async () => {
|
|
1106
|
+
vi.stubEnv('INBOX_BATCH_ITEMS_PER_HOUR', '2');
|
|
1107
|
+
const credential = await signed();
|
|
1108
|
+
const items = [0, 1, 2].map(i => ({ recipient: email(`rate${i}@test.com`), credential }));
|
|
1109
|
+
await expect(issuer.clients.fullAuth.inbox.issueBatch({ items })).rejects.toMatchObject({
|
|
1110
|
+
code: 'TOO_MANY_REQUESTS',
|
|
1111
|
+
message: expect.stringContaining('3600 seconds'),
|
|
1112
|
+
});
|
|
1113
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(0);
|
|
1114
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
1115
|
+
const quota = async () =>
|
|
1116
|
+
(
|
|
1117
|
+
await neogma.queryRunner.run(
|
|
1118
|
+
'MATCH (q:InboxBatchIssuer {id: "batch-issuer"}) RETURN q.used AS used'
|
|
1119
|
+
)
|
|
1120
|
+
).records[0]?.get('used');
|
|
1121
|
+
expect(await quota()).toBeUndefined();
|
|
1122
|
+
expect((await issue({ items: items.slice(0, 2) })).summary.succeeded).toBe(2);
|
|
1123
|
+
expect(Number(await quota())).toBe(2);
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
it('supports guardian approval on the single route and rejects self-approval before writes', async () => {
|
|
1127
|
+
const credential = await signed();
|
|
1128
|
+
const recipient = email('child@test.com');
|
|
1129
|
+
const rejected = await post(
|
|
1130
|
+
{ recipient, credential, configuration: { guardianEmail: 'CHILD@test.com' } },
|
|
1131
|
+
true,
|
|
1132
|
+
'/api/inbox/issue'
|
|
1133
|
+
);
|
|
1134
|
+
expect(rejected.statusCode).toBe(400);
|
|
1135
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(0);
|
|
1136
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
1137
|
+
|
|
1138
|
+
const accepted = await post(
|
|
1139
|
+
{ recipient, credential, configuration: { guardianEmail: 'guardian@test.com' } },
|
|
1140
|
+
true,
|
|
1141
|
+
'/api/inbox/issue'
|
|
1142
|
+
);
|
|
1143
|
+
expect(accepted.statusCode, accepted.body).toBe(200);
|
|
1144
|
+
expect(await stored(accepted.json().issuanceId)).toMatchObject({
|
|
1145
|
+
guardianEmail: 'guardian@test.com',
|
|
1146
|
+
guardianStatus: 'AWAITING_GUARDIAN',
|
|
1147
|
+
});
|
|
1148
|
+
expect(sendSpy).toHaveBeenCalledTimes(2);
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it('releases keys after real preflight rejection and accepts a corrected issuance', async () => {
|
|
1152
|
+
await SigningAuthority.delete({ detach: true, where: {} });
|
|
1153
|
+
const entry = {
|
|
1154
|
+
recipient: email('preflight@test.com'),
|
|
1155
|
+
credential: await unsigned(),
|
|
1156
|
+
idempotencyKey: 'preflight',
|
|
1157
|
+
};
|
|
1158
|
+
expect((await issue({ items: [entry] })).results[0]).toMatchObject({
|
|
1159
|
+
success: false,
|
|
1160
|
+
error: {
|
|
1161
|
+
code: 'BAD_REQUEST',
|
|
1162
|
+
message: 'Unsigned credentials require a signing authority',
|
|
1163
|
+
},
|
|
1164
|
+
});
|
|
1165
|
+
expect(await replay('preflight')).toBeUndefined();
|
|
1166
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(0);
|
|
1167
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
1168
|
+
expect(
|
|
1169
|
+
(await issue({ items: [{ ...entry, credential: await signed() }] })).summary.succeeded
|
|
1170
|
+
).toBe(1);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
it.each(['untrusted-phone', 'invalid-preflight', 'missing-authority'] as const)(
|
|
1174
|
+
'releases keys after %s preflight rejection before any delivery',
|
|
1175
|
+
async scenario => {
|
|
1176
|
+
const credential = await unsigned();
|
|
1177
|
+
let recipient: IssueInboxCredentialBatch['items'][number]['recipient'] =
|
|
1178
|
+
email('preflight@test.com');
|
|
1179
|
+
let configuration: IssueInboxCredentialBatch['configuration'];
|
|
1180
|
+
let code = 'BAD_REQUEST';
|
|
1181
|
+
if (scenario === 'untrusted-phone') {
|
|
1182
|
+
recipient = { type: 'phone', value: '+15555550100' };
|
|
1183
|
+
code = 'FORBIDDEN';
|
|
1184
|
+
} else if (scenario === 'invalid-preflight') {
|
|
1185
|
+
// Unknown JSON-LD property fails the real preflight signer, as in inbox.spec.ts.
|
|
1186
|
+
credential.context = 'banana';
|
|
1187
|
+
} else {
|
|
1188
|
+
const contact = await createContactMethod({ ...recipient, isVerified: true });
|
|
1189
|
+
await createProfileContactMethodRelationship('batch-holder', contact.id);
|
|
1190
|
+
configuration = {
|
|
1191
|
+
signingAuthority: { endpoint: 'https://missing.test', name: 'missing' },
|
|
1192
|
+
};
|
|
1193
|
+
code = 'NOT_FOUND';
|
|
1194
|
+
}
|
|
1195
|
+
const result = await issue({
|
|
1196
|
+
items: [{ recipient, credential, configuration, idempotencyKey: scenario }],
|
|
1197
|
+
});
|
|
1198
|
+
expect(result.results[0]).toMatchObject({ success: false, error: { code } });
|
|
1199
|
+
expect(await replay(scenario)).toBeUndefined();
|
|
1200
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(0);
|
|
1201
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
1202
|
+
expect(
|
|
1203
|
+
(
|
|
1204
|
+
await issue({
|
|
1205
|
+
items: [
|
|
1206
|
+
{
|
|
1207
|
+
recipient: email('corrected@test.com'),
|
|
1208
|
+
credential: await signed(),
|
|
1209
|
+
idempotencyKey: scenario,
|
|
1210
|
+
},
|
|
1211
|
+
],
|
|
1212
|
+
})
|
|
1213
|
+
).summary.succeeded
|
|
1214
|
+
).toBe(1);
|
|
1215
|
+
}
|
|
1216
|
+
);
|
|
1217
|
+
|
|
1218
|
+
it('issues only the first occurrence of a repeated key within one batch', async () => {
|
|
1219
|
+
const credential = await signed();
|
|
1220
|
+
const entry = {
|
|
1221
|
+
recipient: email('duplicate@test.com'),
|
|
1222
|
+
credential,
|
|
1223
|
+
idempotencyKey: 'duplicate',
|
|
1224
|
+
};
|
|
1225
|
+
const batch = await issue({
|
|
1226
|
+
items: [entry, entry, { ...entry, recipient: email('changed@test.com') }],
|
|
1227
|
+
});
|
|
1228
|
+
expect(batch.results).toMatchObject([
|
|
1229
|
+
{ success: true, index: 0 },
|
|
1230
|
+
{ success: false, index: 1, error: { code: 'CONFLICT' } },
|
|
1231
|
+
{ success: false, index: 2, error: { code: 'CONFLICT' } },
|
|
1232
|
+
]);
|
|
1233
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(1);
|
|
1234
|
+
expect(sendSpy).toHaveBeenCalledTimes(1);
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it('rejects empty and oversized batches, unauthenticated callers and read-only grants', async () => {
|
|
1238
|
+
await expect(issuer.clients.fullAuth.inbox.issueBatch({ items: [] })).rejects.toMatchObject(
|
|
1239
|
+
{ code: 'BAD_REQUEST' }
|
|
1240
|
+
);
|
|
1241
|
+
const item = { recipient: email('validation@test.com'), credential: await signed() };
|
|
1242
|
+
await expect(
|
|
1243
|
+
issuer.clients.fullAuth.inbox.issueBatch({ items: Array(101).fill(item) })
|
|
1244
|
+
).rejects.toMatchObject({
|
|
1245
|
+
code: 'BAD_REQUEST',
|
|
1246
|
+
});
|
|
1247
|
+
expect((await post({ items: [item] }, false)).statusCode).toBe(401);
|
|
1248
|
+
await expect(
|
|
1249
|
+
getClient({
|
|
1250
|
+
did: issuer.learnCard.id.did(),
|
|
1251
|
+
isChallengeValid: true,
|
|
1252
|
+
scope: 'inbox:read',
|
|
1253
|
+
}).inbox.issueBatch({ items: [item] })
|
|
1254
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
it('chunks full CLR payloads by bytes and enforces the 4 MiB HTTP boundary', async () => {
|
|
1258
|
+
const credential = structuredClone(clrWestbridgeFull.credential);
|
|
1259
|
+
// Unknown recipients defer signing, so this exercises real encrypted storage without
|
|
1260
|
+
// contacting an authority. This fixture is ~52 KB: 100 exceed the transport budget.
|
|
1261
|
+
const items = Array.from({ length: 100 }, (_, i) => ({
|
|
1262
|
+
recipient: email(`clr${i}@test.com`),
|
|
1263
|
+
credential,
|
|
1264
|
+
configuration: {
|
|
1265
|
+
signingAuthority: { endpoint: 'https://missing.test', name: 'missing' },
|
|
1266
|
+
},
|
|
1267
|
+
}));
|
|
1268
|
+
expect((await post({ items })).statusCode).toBe(413);
|
|
1269
|
+
const payload = {
|
|
1270
|
+
items: items.slice(0, 75),
|
|
1271
|
+
configuration: { templateData: { padding: '' } },
|
|
1272
|
+
};
|
|
1273
|
+
const maxBytes = 4 * 1024 * 1024;
|
|
1274
|
+
expect(Buffer.byteLength(JSON.stringify(payload))).toBeLessThan(maxBytes);
|
|
1275
|
+
payload.configuration.templateData.padding = 'x'.repeat(
|
|
1276
|
+
maxBytes - Buffer.byteLength(JSON.stringify(payload))
|
|
1277
|
+
);
|
|
1278
|
+
const response = await post(payload);
|
|
1279
|
+
expect(response.statusCode, response.body).toBe(202);
|
|
1280
|
+
const completed = await poll(response.json().batchId);
|
|
1281
|
+
expect(completed.results).toHaveLength(75);
|
|
1282
|
+
expect(completed.summary).toEqual({
|
|
1283
|
+
total: 75,
|
|
1284
|
+
succeeded: 75,
|
|
1285
|
+
failed: 0,
|
|
1286
|
+
deduplicated: 0,
|
|
1287
|
+
});
|
|
1288
|
+
const before = await InboxCredential.findMany({ where: {} });
|
|
1289
|
+
payload.configuration.templateData.padding += 'x';
|
|
1290
|
+
const tooLarge = await post(payload);
|
|
1291
|
+
expect(tooLarge.statusCode).toBe(413);
|
|
1292
|
+
expect(await InboxCredential.findMany({ where: {} })).toHaveLength(before.length);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
it('accepts a single unkeyed item at exactly 4 MiB even after the worker adds its internal key', async () => {
|
|
1296
|
+
const batch = {
|
|
1297
|
+
items: [{ recipient: email('exact@test.com'), credential: await signed() }],
|
|
1298
|
+
configuration: { templateData: { padding: '' } },
|
|
1299
|
+
};
|
|
1300
|
+
batch.configuration.templateData.padding = 'x'.repeat(
|
|
1301
|
+
4 * 1024 * 1024 - Buffer.byteLength(JSON.stringify(batch))
|
|
1302
|
+
);
|
|
1303
|
+
expect((await issue(batch)).summary.succeeded).toBe(1);
|
|
1304
|
+
batch.configuration.templateData.padding += 'x';
|
|
1305
|
+
expect((await post(batch)).statusCode).toBe(413);
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
it('gates only the item configured with a guardian email', async () => {
|
|
1309
|
+
const credential = await signed();
|
|
1310
|
+
const batch = await issue({
|
|
1311
|
+
configuration: { guardianEmail: 'guardian@test.com' },
|
|
1312
|
+
items: [
|
|
1313
|
+
{
|
|
1314
|
+
recipient: email('child@test.com'),
|
|
1315
|
+
credential,
|
|
1316
|
+
},
|
|
1317
|
+
{
|
|
1318
|
+
recipient: email('adult@test.com'),
|
|
1319
|
+
credential,
|
|
1320
|
+
configuration: { guardianEmail: null },
|
|
1321
|
+
},
|
|
1322
|
+
],
|
|
1323
|
+
});
|
|
1324
|
+
expect(batch.results[0]).toMatchObject({
|
|
1325
|
+
success: true,
|
|
1326
|
+
status: 'PENDING',
|
|
1327
|
+
guardianStatus: 'AWAITING_GUARDIAN',
|
|
1328
|
+
});
|
|
1329
|
+
expect(batch.results[1]).toMatchObject({ success: true, status: 'PENDING' });
|
|
1330
|
+
expect(batch.results[1]).not.toHaveProperty('guardianStatus', 'AWAITING_GUARDIAN');
|
|
1331
|
+
const first = batch.results[0]!;
|
|
1332
|
+
if (!first.success) throw new Error('Expected successful issuance');
|
|
1333
|
+
expect(await stored(first.issuanceId)).toMatchObject({
|
|
1334
|
+
guardianEmail: 'guardian@test.com',
|
|
1335
|
+
guardianStatus: 'AWAITING_GUARDIAN',
|
|
1336
|
+
});
|
|
1337
|
+
});
|
|
1338
|
+
});
|