@learncard/network-brain-service 3.17.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/AGENTS.md +177 -159
- package/CHANGELOG.md +148 -0
- package/README.md +26 -8
- package/compose.inbox.yml +7 -0
- package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/auth-grant/read.d.ts +1 -0
- package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +3 -2
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +5 -3
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential/create.d.ts +2 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/create.test.d.ts +2 -0
- package/dist/accesslayer/credential/create.test.d.ts.map +1 -0
- package/dist/accesslayer/credential/relationships/read.d.ts +1 -0
- package/dist/accesslayer/credential/relationships/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/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/activity.helpers.d.ts +4 -0
- package/dist/helpers/activity.helpers.d.ts.map +1 -1
- package/dist/helpers/boost-storage.helpers.test.d.ts +2 -0
- package/dist/helpers/boost-storage.helpers.test.d.ts.map +1 -0
- package/dist/helpers/boost.helpers.d.ts +3 -7
- package/dist/helpers/boost.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 +4 -2
- 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/issuedCredentialStatus.helpers.d.ts +5 -0
- package/dist/helpers/issuedCredentialStatus.helpers.d.ts.map +1 -0
- 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/helpers/signingAuthority.helpers.d.ts +3 -2
- package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
- package/dist/helpers/signingAuthority.helpers.test.d.ts +2 -0
- package/dist/helpers/signingAuthority.helpers.test.d.ts.map +1 -0
- package/dist/helpers/status-list.helpers.d.ts.map +1 -1
- package/dist/helpers/status-list.helpers.test.d.ts +2 -0
- package/dist/helpers/status-list.helpers.test.d.ts.map +1 -0
- 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/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
- package/dist/models/Credential.d.ts +1 -0
- package/dist/models/Credential.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/app-store.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 +506 -12
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +48 -2
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-refreshes.d.ts +4 -4
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +8 -2
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/did-metadata.d.ts +8 -8
- package/dist/routes/federation.d.ts +2 -2
- package/dist/routes/inbox.d.ts +973 -31
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +47 -127
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +6 -6
- package/dist/routes/storage.d.ts +63 -63
- package/dist/routes/test.d.ts +6 -0
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts +14 -14
- 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/consentflowcontract.d.ts +22 -8
- package/dist/types/consentflowcontract.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/credential.d.ts +7 -0
- package/dist/types/credential.d.ts.map +1 -1
- package/dist/types/did-metadata.d.ts +2 -2
- 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/app-store-listing/relationships/read.ts +4 -3
- package/src/accesslayer/auth-grant/read.ts +2 -1
- package/src/accesslayer/consentflowcontract/relationships/create.ts +15 -5
- package/src/accesslayer/consentflowcontract/relationships/read.ts +161 -63
- package/src/accesslayer/consentflowcontract/relationships/update.ts +43 -10
- package/src/accesslayer/credential/create.test.ts +141 -0
- package/src/accesslayer/credential/create.ts +27 -3
- 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/boost-storage.helpers.test.ts +242 -0
- package/src/helpers/boost.helpers.ts +58 -437
- 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 +385 -107
- package/src/helpers/credential.helpers.ts +6 -2
- package/src/helpers/finalize-inbox.helpers.ts +39 -22
- 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 +301 -50
- package/src/helpers/issuedCredentialStatus.helpers.ts +11 -0
- 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/helpers/signingAuthority.helpers.test.ts +198 -0
- package/src/helpers/signingAuthority.helpers.ts +41 -18
- package/src/helpers/status-list.helpers.test.ts +77 -0
- package/src/helpers/status-list.helpers.ts +31 -8
- package/src/inbox-queue-local.ts +28 -0
- package/src/models/AuthGrant.ts +1 -0
- package/src/models/ConsentFlowTerms.ts +4 -0
- package/src/models/ConsentFlowTransaction.ts +4 -0
- package/src/models/Credential.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 +69 -52
- package/src/routes/auth-grants.ts +13 -11
- package/src/routes/boosts.ts +943 -120
- package/src/routes/contracts.ts +55 -7
- 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 +164 -88
- package/src/routes/skills.ts +1 -1
- package/src/routes/workflows.ts +80 -31
- 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/consentflowcontract.ts +21 -3
- package/src/types/credential-refresh.ts +21 -2
- package/src/types/credential.ts +13 -1
- package/src/types/inbox-batch.ts +46 -0
- package/test/app-store-listing.spec.ts +66 -30
- package/test/atomic-counter.e2e.ts +12 -0
- package/test/auth-grant.spec.ts +21 -21
- package/test/bitstring-status-list.spec.ts +12 -9
- package/test/consentflow.spec.ts +453 -26
- package/test/credential-refresh-email.spec.ts +663 -0
- package/test/credential-refresh-publication.spec.ts +13 -5
- package/test/guardian-gated-routes.spec.ts +337 -180
- 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/inbox-security.e2e.ts +48 -3
- package/test/openapi.spec.ts +41 -1
- package/test/refresh-send.spec.ts +1799 -0
- package/test/registry.spec.ts +56 -11
- package/test/revoke-suspend-per-instance.spec.ts +9 -1
- package/vitest.config.ts +13 -0
- package/vitest.inbox-e2e.config.ts +5 -1
- package/vitest.integration.config.ts +2 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
|
2
|
+
import type { UnsignedVC, VP } from '@learncard/types';
|
|
3
|
+
const mocks = vi.hoisted(() => ({ sign: vi.fn(), verify: vi.fn(), sendEmail: vi.fn() }));
|
|
4
|
+
vi.mock('@helpers/signingAuthority.helpers', async original => ({
|
|
5
|
+
...(await original<Record<string, unknown>>()),
|
|
6
|
+
issueCredentialWithSigningAuthority: mocks.sign,
|
|
7
|
+
}));
|
|
8
|
+
// This suite tests DB races and lifecycle; real-signing E2E covers authority transport/proofs.
|
|
9
|
+
vi.mock('@helpers/credential-refresh-proof.helpers', () => ({
|
|
10
|
+
verifyManagedRefreshProof: mocks.verify,
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('@services/delivery/delivery.factory', () => ({
|
|
13
|
+
getDeliveryService: () => ({ send: mocks.sendEmail }),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('@services/registry/registry.factory', () => ({
|
|
16
|
+
getRegistryService: () => ({ isTrusted: async () => true }),
|
|
17
|
+
}));
|
|
18
|
+
import { neogma } from '@instance';
|
|
19
|
+
import { getUser, getClient } from './helpers/getClient';
|
|
20
|
+
import { getContactMethodByValue } from '@accesslayer/contact-method/read';
|
|
21
|
+
import { createProfileContactMethodRelationship } from '@accesslayer/contact-method/relationships/create';
|
|
22
|
+
import { verifyContactMethod } from '@accesslayer/contact-method/update';
|
|
23
|
+
import { getProfileByProfileId } from '@accesslayer/profile/read';
|
|
24
|
+
import { getInboxCredentialById } from '@accesslayer/inbox-credential/read';
|
|
25
|
+
import {
|
|
26
|
+
updateInboxCredential,
|
|
27
|
+
expireInboxCredentials,
|
|
28
|
+
} from '@accesslayer/inbox-credential/update';
|
|
29
|
+
import { getCredentialRefresh, getCredentialRefreshHead } from '@accesslayer/credential-refresh';
|
|
30
|
+
import { finalizeInboxRefresh } from '@helpers/inbox-refresh.helpers';
|
|
31
|
+
import { decryptInboxCredential } from '@helpers/inbox-encryption.helpers';
|
|
32
|
+
import * as notifications from '@helpers/notifications.helpers';
|
|
33
|
+
|
|
34
|
+
const DOMAIN = 'localhost%3A3000';
|
|
35
|
+
const SA = { endpoint: 'https://sa.example.com', name: 'inbox-refresh' };
|
|
36
|
+
let issuer: Awaited<ReturnType<typeof getUser>>;
|
|
37
|
+
let holder: Awaited<ReturnType<typeof getUser>>;
|
|
38
|
+
let stranger: Awaited<ReturnType<typeof getUser>>;
|
|
39
|
+
const template = (): UnsignedVC => ({
|
|
40
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
41
|
+
type: ['VerifiableCredential'],
|
|
42
|
+
name: 'Provisional Results',
|
|
43
|
+
issuer: 'did:web:localhost%3A3000:users:inbox-refresh-issuer',
|
|
44
|
+
credentialSubject: {},
|
|
45
|
+
});
|
|
46
|
+
const issue = (overrides = {}) =>
|
|
47
|
+
issuer.clients.fullAuth.inbox.issue({
|
|
48
|
+
recipient: { type: 'email', value: 'unknown@example.com' },
|
|
49
|
+
credential: template(),
|
|
50
|
+
refresh: true,
|
|
51
|
+
configuration: { signingAuthority: SA, delivery: { suppress: true } },
|
|
52
|
+
...overrides,
|
|
53
|
+
});
|
|
54
|
+
const queue = async (
|
|
55
|
+
issued: Awaited<ReturnType<typeof issue>>,
|
|
56
|
+
name: string,
|
|
57
|
+
idempotencyKey?: string
|
|
58
|
+
) => {
|
|
59
|
+
const inbox = (await getInboxCredentialById(issued.issuanceId))!;
|
|
60
|
+
const body = JSON.parse(await decryptInboxCredential(inbox.credential));
|
|
61
|
+
return issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
|
|
62
|
+
refreshId: issued.refresh!.refreshId,
|
|
63
|
+
mode: 'signing-authority',
|
|
64
|
+
credential: { ...body, name },
|
|
65
|
+
signingAuthority: { type: 'LearnCardSigningAuthority', ...SA },
|
|
66
|
+
idempotencyKey,
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
const claim = async (issued: Awaited<ReturnType<typeof issue>>) =>
|
|
70
|
+
finalizeInboxRefresh({
|
|
71
|
+
inboxId: issued.issuanceId,
|
|
72
|
+
holderDid: holder.learnCard.id.did(),
|
|
73
|
+
holderProfile: await getProfileByProfileId('inbox-refresh-holder'),
|
|
74
|
+
domain: DOMAIN,
|
|
75
|
+
});
|
|
76
|
+
const replay = (refreshId: string, idempotencyKey: string) =>
|
|
77
|
+
issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
|
|
78
|
+
mode: 'signing-authority',
|
|
79
|
+
refreshId,
|
|
80
|
+
credential: template(),
|
|
81
|
+
signingAuthority: { type: 'LearnCardSigningAuthority', ...SA },
|
|
82
|
+
idempotencyKey,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
beforeAll(async () => {
|
|
86
|
+
issuer = await getUser('e'.repeat(64));
|
|
87
|
+
holder = await getUser('9'.repeat(64));
|
|
88
|
+
stranger = await getUser('1'.repeat(64));
|
|
89
|
+
vi.spyOn(notifications, 'addNotificationToQueue').mockResolvedValue();
|
|
90
|
+
});
|
|
91
|
+
beforeEach(async () => {
|
|
92
|
+
mocks.sendEmail.mockReset().mockResolvedValue(undefined);
|
|
93
|
+
// @instance is the isolated Testcontainers database, never the user's local demo stack.
|
|
94
|
+
await neogma.queryRunner.run('MATCH (n) DETACH DELETE n');
|
|
95
|
+
await issuer.clients.fullAuth.profile.createProfile({ profileId: 'inbox-refresh-issuer' });
|
|
96
|
+
await holder.clients.fullAuth.profile.createProfile({ profileId: 'inbox-refresh-holder' });
|
|
97
|
+
await stranger.clients.fullAuth.profile.createProfile({ profileId: 'inbox-refresh-stranger' });
|
|
98
|
+
await issuer.clients.fullAuth.profile.registerSigningAuthority({
|
|
99
|
+
...SA,
|
|
100
|
+
did: issuer.learnCard.id.did(),
|
|
101
|
+
});
|
|
102
|
+
mocks.sign.mockReset();
|
|
103
|
+
mocks.verify.mockReset();
|
|
104
|
+
mocks.sign.mockImplementation(async (_owner, body) => ({
|
|
105
|
+
kind: 'issued-credential',
|
|
106
|
+
credential: {
|
|
107
|
+
...body,
|
|
108
|
+
proof: {
|
|
109
|
+
type: 'DataIntegrityProof',
|
|
110
|
+
cryptosuite: 'eddsa-rdfc-2022',
|
|
111
|
+
proofValue: 'test',
|
|
112
|
+
created: new Date().toISOString(),
|
|
113
|
+
proofPurpose: 'assertionMethod',
|
|
114
|
+
verificationMethod: issuer.learnCard.id.did(),
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
statusEntries: [],
|
|
118
|
+
}));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('managed Universal Inbox refresh', () => {
|
|
122
|
+
it.each(['email', 'phone'] as const)(
|
|
123
|
+
'allocates for unknown %s, claims latest, then publishes normally',
|
|
124
|
+
async type => {
|
|
125
|
+
const issued = await issue({
|
|
126
|
+
recipient: {
|
|
127
|
+
type,
|
|
128
|
+
value: type === 'email' ? 'unknown@example.com' : '+12025550123',
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
expect(issued.status).toBe('PENDING');
|
|
132
|
+
expect(issued.refresh?.holderDid).toBeUndefined();
|
|
133
|
+
expect(mocks.sign).not.toHaveBeenCalled();
|
|
134
|
+
const id = issued.refresh!.refreshId;
|
|
135
|
+
expect(await getCredentialRefresh(id)).toMatchObject({
|
|
136
|
+
state: 'pending_holder',
|
|
137
|
+
currentVersion: 1,
|
|
138
|
+
});
|
|
139
|
+
expect((await getInboxCredentialById(issued.issuanceId))?.credential).toMatch(
|
|
140
|
+
/^lc-inbox-jwe:v1:/
|
|
141
|
+
);
|
|
142
|
+
expect(await getCredentialRefreshHead(id)).toBeNull();
|
|
143
|
+
expect(await queue(issued, 'Review complete', 'update-1')).toMatchObject({
|
|
144
|
+
version: 2,
|
|
145
|
+
notification: 'not-applicable',
|
|
146
|
+
});
|
|
147
|
+
expect(await queue(issued, 'Final grade A', 'update-2')).toMatchObject({ version: 3 });
|
|
148
|
+
const delivered = await claim(issued);
|
|
149
|
+
expect(delivered.credential.name).toBe('Final grade A');
|
|
150
|
+
expect(delivered.credential.refreshService).toMatchObject({
|
|
151
|
+
id: issued.refresh!.refreshService.id,
|
|
152
|
+
});
|
|
153
|
+
expect(await getCredentialRefresh(id)).toMatchObject({
|
|
154
|
+
state: 'active',
|
|
155
|
+
currentVersion: 3,
|
|
156
|
+
holderDid: holder.learnCard.id.did(),
|
|
157
|
+
});
|
|
158
|
+
expect((await getInboxCredentialById(issued.issuanceId))?.credential).toBeUndefined();
|
|
159
|
+
const head = (await getCredentialRefreshHead(id))!;
|
|
160
|
+
expect(head.version).toBe(3);
|
|
161
|
+
expect(
|
|
162
|
+
await holder.learnCard.invoke.decryptDagJwe(JSON.parse(head.credential))
|
|
163
|
+
).toMatchObject({ name: 'Final grade A' });
|
|
164
|
+
const issuerRead = await issuer.learnCard.invoke
|
|
165
|
+
.decryptDagJwe(JSON.parse(head.credential))
|
|
166
|
+
.catch(() => null);
|
|
167
|
+
expect(issuerRead).toBeFalsy();
|
|
168
|
+
const published =
|
|
169
|
+
await issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
|
|
170
|
+
mode: 'signing-authority',
|
|
171
|
+
refreshId: id,
|
|
172
|
+
credential: {
|
|
173
|
+
...delivered.credential,
|
|
174
|
+
proof: undefined,
|
|
175
|
+
name: 'Final grade A with honors',
|
|
176
|
+
},
|
|
177
|
+
signingAuthority: { type: 'LearnCardSigningAuthority', ...SA },
|
|
178
|
+
idempotencyKey: 'after-claim',
|
|
179
|
+
});
|
|
180
|
+
expect(published.version).toBe(4);
|
|
181
|
+
expect(await replay(id, 'update-1')).toMatchObject({ version: 2 });
|
|
182
|
+
const history =
|
|
183
|
+
await issuer.clients.fullAuth.credentialRefresh.getCredentialRefreshHistory({
|
|
184
|
+
refreshId: id,
|
|
185
|
+
});
|
|
186
|
+
expect(history.records.map(v => v.version)).toEqual([4, 3, 2, 1]);
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
it('reuses one issuance/allocation and rejects a changed keyed request', async () => {
|
|
190
|
+
const first = await issue({ idempotencyKey: 'issue-key' });
|
|
191
|
+
expect(await issue({ idempotencyKey: 'issue-key' })).toEqual(first);
|
|
192
|
+
await expect(
|
|
193
|
+
issue({ idempotencyKey: 'issue-key', credential: { ...template(), name: 'Different' } })
|
|
194
|
+
).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
195
|
+
expect(
|
|
196
|
+
Number(
|
|
197
|
+
(
|
|
198
|
+
await neogma.queryRunner.run(
|
|
199
|
+
'MATCH (ic:InboxCredential) RETURN count(ic) AS count'
|
|
200
|
+
)
|
|
201
|
+
).records[0]?.get('count')
|
|
202
|
+
)
|
|
203
|
+
).toBe(1);
|
|
204
|
+
});
|
|
205
|
+
it('re-signs latest content when publication wins during claim', async () => {
|
|
206
|
+
const issued = await issue();
|
|
207
|
+
mocks.sign.mockImplementationOnce(async (_owner, body) => {
|
|
208
|
+
await queue(issued, 'Published while signing', 'racing-update');
|
|
209
|
+
return {
|
|
210
|
+
kind: 'issued-credential',
|
|
211
|
+
credential: {
|
|
212
|
+
...body,
|
|
213
|
+
proof: {
|
|
214
|
+
type: 'test',
|
|
215
|
+
created: new Date().toISOString(),
|
|
216
|
+
proofPurpose: 'assertionMethod',
|
|
217
|
+
verificationMethod: issuer.learnCard.id.did(),
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
statusEntries: [],
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
expect((await claim(issued)).credential.name).toBe('Published while signing');
|
|
224
|
+
expect(mocks.sign).toHaveBeenCalledTimes(2);
|
|
225
|
+
});
|
|
226
|
+
it('binds only one claimant and refuses guardian-gated or expired claims', async () => {
|
|
227
|
+
const issued = await issue();
|
|
228
|
+
await updateInboxCredential(issued.issuanceId, { guardianStatus: 'AWAITING_GUARDIAN' });
|
|
229
|
+
await expect(claim(issued)).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
230
|
+
await updateInboxCredential(issued.issuanceId, { guardianStatus: 'GUARDIAN_APPROVED' });
|
|
231
|
+
const results = await Promise.allSettled([
|
|
232
|
+
claim(issued),
|
|
233
|
+
finalizeInboxRefresh({
|
|
234
|
+
inboxId: issued.issuanceId,
|
|
235
|
+
holderDid: stranger.learnCard.id.did(),
|
|
236
|
+
holderProfile: await getProfileByProfileId('inbox-refresh-stranger'),
|
|
237
|
+
domain: DOMAIN,
|
|
238
|
+
}),
|
|
239
|
+
]);
|
|
240
|
+
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1);
|
|
241
|
+
const expired = await issue({ recipient: { type: 'email', value: 'expired@example.com' } });
|
|
242
|
+
await updateInboxCredential(expired.issuanceId, { expiresAt: '2000-01-01T00:00:00.000Z' });
|
|
243
|
+
await expect(claim(expired)).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
244
|
+
await expect(queue(expired, 'Too late')).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
245
|
+
await expireInboxCredentials();
|
|
246
|
+
expect((await getInboxCredentialById(expired.issuanceId))?.credential).toBeUndefined();
|
|
247
|
+
});
|
|
248
|
+
it('supports the profile finalize route and exposes a bound receipt to the issuer', async () => {
|
|
249
|
+
const issued = await issue();
|
|
250
|
+
await queue(issued, 'Latest at profile claim');
|
|
251
|
+
const contact = (await getContactMethodByValue('email', 'unknown@example.com'))!;
|
|
252
|
+
await createProfileContactMethodRelationship('inbox-refresh-holder', contact.id);
|
|
253
|
+
await verifyContactMethod(contact.id);
|
|
254
|
+
await updateInboxCredential(issued.issuanceId, { isAccepted: true });
|
|
255
|
+
const result = await holder.clients.fullAuth.inbox.finalize({});
|
|
256
|
+
expect(result).toMatchObject({ claimed: 1, errors: 0 });
|
|
257
|
+
expect(result.verifiableCredentials[0]?.name).toBe('Latest at profile claim');
|
|
258
|
+
const metadata = await issuer.clients.fullAuth.inbox.getInboxCredential({
|
|
259
|
+
credentialId: issued.issuanceId,
|
|
260
|
+
});
|
|
261
|
+
expect(metadata.refresh?.holderDid).toBe(holder.learnCard.id.did());
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('supports the claim-link exchange with the newest pending version', async () => {
|
|
265
|
+
const issued = await issue();
|
|
266
|
+
await queue(issued, 'Latest at link claim');
|
|
267
|
+
const localExchangeId = new URL(issued.claimUrl!).pathname.split('/').pop()!;
|
|
268
|
+
const start = await holder.clients.fullAuth.workflows.participateInExchange({
|
|
269
|
+
localWorkflowId: 'inbox-claim',
|
|
270
|
+
localExchangeId,
|
|
271
|
+
});
|
|
272
|
+
const vp = (await holder.learnCard.invoke.getDidAuthVp({
|
|
273
|
+
challenge: start.verifiablePresentationRequest?.challenge,
|
|
274
|
+
domain: start.verifiablePresentationRequest?.domain,
|
|
275
|
+
})) as VP;
|
|
276
|
+
const result = await holder.clients.fullAuth.workflows.participateInExchange({
|
|
277
|
+
localWorkflowId: 'inbox-claim',
|
|
278
|
+
localExchangeId,
|
|
279
|
+
verifiablePresentation: vp,
|
|
280
|
+
});
|
|
281
|
+
expect(result.verifiablePresentation?.verifiableCredential?.[0]).toMatchObject({
|
|
282
|
+
name: 'Latest at link claim',
|
|
283
|
+
});
|
|
284
|
+
expect((await getCredentialRefresh(issued.refresh!.refreshId))?.state).toBe('active');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('auto-delivers to a verified existing recipient with refresh enabled', async () => {
|
|
288
|
+
await holder.clients.fullAuth.contactMethods.addContactMethod({
|
|
289
|
+
type: 'email',
|
|
290
|
+
value: 'known@example.com',
|
|
291
|
+
});
|
|
292
|
+
await verifyContactMethod(
|
|
293
|
+
(await getContactMethodByValue('email', 'known@example.com'))!.id
|
|
294
|
+
);
|
|
295
|
+
mocks.sendEmail.mockClear(); // Ignore contact-method verification delivery above.
|
|
296
|
+
const issued = await issue({ recipient: { type: 'email', value: 'known@example.com' } });
|
|
297
|
+
expect(issued).toMatchObject({
|
|
298
|
+
status: 'ISSUED',
|
|
299
|
+
recipientDid: holder.learnCard.id.did(),
|
|
300
|
+
refresh: { holderDid: holder.learnCard.id.did() },
|
|
301
|
+
});
|
|
302
|
+
expect((await getInboxCredentialById(issued.issuanceId))?.credential).toBeUndefined();
|
|
303
|
+
expect((await getCredentialRefresh(issued.refresh!.refreshId))?.state).toBe(
|
|
304
|
+
'awaiting_claim'
|
|
305
|
+
);
|
|
306
|
+
expect(mocks.sendEmail).not.toHaveBeenCalled();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('emails existing recipients once with an app link and preserves delivery if email fails', async () => {
|
|
310
|
+
await holder.clients.fullAuth.contactMethods.addContactMethod({
|
|
311
|
+
type: 'email',
|
|
312
|
+
value: 'known@example.com',
|
|
313
|
+
});
|
|
314
|
+
await verifyContactMethod(
|
|
315
|
+
(await getContactMethodByValue('email', 'known@example.com'))!.id
|
|
316
|
+
);
|
|
317
|
+
mocks.sendEmail.mockClear(); // Ignore contact-method verification delivery above.
|
|
318
|
+
mocks.sendEmail.mockRejectedValueOnce(new Error('Email provider unavailable'));
|
|
319
|
+
const input = {
|
|
320
|
+
recipient: { type: 'email' as const, value: 'known@example.com' },
|
|
321
|
+
idempotencyKey: 'known-email',
|
|
322
|
+
configuration: { signingAuthority: SA, delivery: { suppress: false } },
|
|
323
|
+
};
|
|
324
|
+
const issued = await issue(input);
|
|
325
|
+
expect(issued.status).toBe('ISSUED');
|
|
326
|
+
expect(mocks.sendEmail).toHaveBeenCalledTimes(1);
|
|
327
|
+
expect(mocks.sendEmail).toHaveBeenCalledWith(
|
|
328
|
+
expect.objectContaining({
|
|
329
|
+
contactMethod: input.recipient,
|
|
330
|
+
templateId: 'universal-inbox-claim',
|
|
331
|
+
templateModel: expect.objectContaining({
|
|
332
|
+
claimUrl: expect.stringMatching(/\/notifications$/),
|
|
333
|
+
}),
|
|
334
|
+
})
|
|
335
|
+
);
|
|
336
|
+
expect((await issue(input)).issuanceId).toBe(issued.issuanceId);
|
|
337
|
+
expect(mocks.sendEmail).toHaveBeenCalledTimes(1);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('routes unified send to the same inbox lifecycle and reuses its boost on retry', async () => {
|
|
341
|
+
await issuer.clients.fullAuth.profile.setPrimarySigningAuthority(SA);
|
|
342
|
+
const input = {
|
|
343
|
+
type: 'boost' as const,
|
|
344
|
+
recipient: 'unknown@example.com',
|
|
345
|
+
template: { credential: template() },
|
|
346
|
+
refresh: true,
|
|
347
|
+
idempotencyKey: 'unified-key',
|
|
348
|
+
options: { suppressDelivery: true },
|
|
349
|
+
};
|
|
350
|
+
const first = await issuer.clients.fullAuth.boost.send(input);
|
|
351
|
+
expect(first.inbox?.refresh?.refreshId).toBeTruthy();
|
|
352
|
+
expect(await issuer.clients.fullAuth.boost.send(input)).toEqual(first);
|
|
353
|
+
expect(
|
|
354
|
+
Number(
|
|
355
|
+
(
|
|
356
|
+
await neogma.queryRunner.run('MATCH (b:Boost) RETURN count(b) AS count')
|
|
357
|
+
).records[0]?.get('count')
|
|
358
|
+
)
|
|
359
|
+
).toBe(1);
|
|
360
|
+
await expect(
|
|
361
|
+
issuer.clients.fullAuth.boost.send({ ...input, recipient: 'different@example.com' })
|
|
362
|
+
).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('retains escrow when signing fails and can retry the same claim', async () => {
|
|
366
|
+
const issued = await issue();
|
|
367
|
+
mocks.sign.mockRejectedValueOnce(new Error('Authority unavailable'));
|
|
368
|
+
await expect(claim(issued)).rejects.toThrow('Authority unavailable');
|
|
369
|
+
expect((await getInboxCredentialById(issued.issuanceId))?.credential).toMatch(
|
|
370
|
+
/^lc-inbox-jwe:v1:/
|
|
371
|
+
);
|
|
372
|
+
expect((await getCredentialRefresh(issued.refresh!.refreshId))?.holderDid).toBeUndefined();
|
|
373
|
+
expect((await claim(issued)).credential.name).toBe('Provisional Results');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('keeps concurrent issue retries on a single aggregate and inbox record', async () => {
|
|
377
|
+
const results = await Promise.all([
|
|
378
|
+
issue({ idempotencyKey: 'concurrent' }),
|
|
379
|
+
issue({ idempotencyKey: 'concurrent' }),
|
|
380
|
+
]);
|
|
381
|
+
expect(results[0].issuanceId).toBe(results[1].issuanceId);
|
|
382
|
+
expect(results[0].refresh).toEqual(results[1].refresh);
|
|
383
|
+
expect(
|
|
384
|
+
Number(
|
|
385
|
+
(
|
|
386
|
+
await neogma.queryRunner.run(
|
|
387
|
+
'MATCH (r:CredentialRefresh) RETURN count(r) AS count'
|
|
388
|
+
)
|
|
389
|
+
).records[0]?.get('count')
|
|
390
|
+
)
|
|
391
|
+
).toBe(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('returns a valid allocation receipt for VC1 without status entries', async () => {
|
|
395
|
+
const issued = await issue({
|
|
396
|
+
credential: {
|
|
397
|
+
...template(),
|
|
398
|
+
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
399
|
+
issuanceDate: new Date().toISOString(),
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
expect(issued.refresh?.credentialStatus).toBeUndefined();
|
|
403
|
+
expect((await claim(issued)).credential.id).toBe(issued.refresh?.credentialId);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('resumes notification delivery after an auto-delivery bind committed', async () => {
|
|
407
|
+
await holder.clients.fullAuth.contactMethods.addContactMethod({
|
|
408
|
+
type: 'email',
|
|
409
|
+
value: 'retry-known@example.com',
|
|
410
|
+
});
|
|
411
|
+
await verifyContactMethod(
|
|
412
|
+
(await getContactMethodByValue('email', 'retry-known@example.com'))!.id
|
|
413
|
+
);
|
|
414
|
+
vi.mocked(notifications.addNotificationToQueue).mockRejectedValueOnce(
|
|
415
|
+
new Error('Queue unavailable')
|
|
416
|
+
);
|
|
417
|
+
const options = {
|
|
418
|
+
recipient: { type: 'email', value: 'retry-known@example.com' },
|
|
419
|
+
idempotencyKey: 'known-retry',
|
|
420
|
+
};
|
|
421
|
+
await expect(issue(options)).rejects.toThrow();
|
|
422
|
+
const retried = await issue(options);
|
|
423
|
+
expect(retried).toMatchObject({
|
|
424
|
+
status: 'ISSUED',
|
|
425
|
+
recipientDid: holder.learnCard.id.did(),
|
|
426
|
+
});
|
|
427
|
+
expect(mocks.sign).toHaveBeenCalledTimes(1);
|
|
428
|
+
expect(
|
|
429
|
+
(await getCredentialRefresh(retried.refresh!.refreshId))?.initialNotificationSentAt
|
|
430
|
+
).toBeTruthy();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('concurrent pending publications with one key create one revision', async () => {
|
|
434
|
+
const issued = await issue();
|
|
435
|
+
const outcomes = await Promise.all([
|
|
436
|
+
queue(issued, 'Final', 'same-key'),
|
|
437
|
+
queue(issued, 'Final', 'same-key'),
|
|
438
|
+
]);
|
|
439
|
+
expect(outcomes[0]).toEqual(outcomes[1]);
|
|
440
|
+
expect((await getCredentialRefresh(issued.refresh!.refreshId))?.currentVersion).toBe(2);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('rejects changed pending identity and mechanisms without advancing the version', async () => {
|
|
444
|
+
const issued = await issue();
|
|
445
|
+
const inbox = (await getInboxCredentialById(issued.issuanceId))!;
|
|
446
|
+
const body = JSON.parse(await decryptInboxCredential(inbox.credential));
|
|
447
|
+
for (const patch of [
|
|
448
|
+
{ id: 'urn:uuid:different' },
|
|
449
|
+
{ issuer: stranger.learnCard.id.did() },
|
|
450
|
+
{ credentialSubject: { id: stranger.learnCard.id.did() } },
|
|
451
|
+
{ credentialStatus: undefined },
|
|
452
|
+
{ refreshService: { ...body.refreshService, id: 'https://example.com/refresh' } },
|
|
453
|
+
{ boostId: 'different-boost' },
|
|
454
|
+
]) {
|
|
455
|
+
await expect(
|
|
456
|
+
issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
|
|
457
|
+
mode: 'signing-authority',
|
|
458
|
+
refreshId: issued.refresh!.refreshId,
|
|
459
|
+
credential: { ...body, ...patch },
|
|
460
|
+
signingAuthority: { type: 'LearnCardSigningAuthority', ...SA },
|
|
461
|
+
})
|
|
462
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
463
|
+
}
|
|
464
|
+
expect((await getCredentialRefresh(issued.refresh!.refreshId))?.currentVersion).toBe(1);
|
|
465
|
+
expect(mocks.sign).not.toHaveBeenCalled();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('requires credentials:write in addition to inbox:write before allocation', async () => {
|
|
469
|
+
const limited = getClient({
|
|
470
|
+
did: issuer.learnCard.id.did(),
|
|
471
|
+
isChallengeValid: true,
|
|
472
|
+
scope: 'inbox:write',
|
|
473
|
+
});
|
|
474
|
+
await expect(
|
|
475
|
+
limited.inbox.issue({
|
|
476
|
+
recipient: { type: 'email', value: 'unknown@example.com' },
|
|
477
|
+
credential: template(),
|
|
478
|
+
refresh: true,
|
|
479
|
+
configuration: { signingAuthority: SA, delivery: { suppress: true } },
|
|
480
|
+
})
|
|
481
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
482
|
+
const result = await neogma.queryRunner.run(
|
|
483
|
+
'MATCH (r:CredentialRefresh) RETURN count(r) AS count'
|
|
484
|
+
);
|
|
485
|
+
expect(Number(result.records[0]?.get('count'))).toBe(0);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('preserves the effective time across claim for later publication validation', async () => {
|
|
489
|
+
const issued = await issue({
|
|
490
|
+
credential: { ...template(), validFrom: '2026-09-17T00:00:00.000Z' },
|
|
491
|
+
});
|
|
492
|
+
const delivered = await claim(issued);
|
|
493
|
+
expect((await getCredentialRefreshHead(issued.refresh!.refreshId))?.effectiveAt).toBe(
|
|
494
|
+
'2026-09-17T00:00:00.000Z'
|
|
495
|
+
);
|
|
496
|
+
await expect(
|
|
497
|
+
issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
|
|
498
|
+
mode: 'signing-authority',
|
|
499
|
+
refreshId: issued.refresh!.refreshId,
|
|
500
|
+
credential: {
|
|
501
|
+
...delivered.credential,
|
|
502
|
+
proof: undefined,
|
|
503
|
+
validFrom: '2026-09-16T00:00:00.000Z',
|
|
504
|
+
},
|
|
505
|
+
signingAuthority: { type: 'LearnCardSigningAuthority', ...SA },
|
|
506
|
+
})
|
|
507
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('rejects another issuer and pre-signed pending content', async () => {
|
|
511
|
+
const issued = await issue();
|
|
512
|
+
await expect(
|
|
513
|
+
stranger.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
|
|
514
|
+
mode: 'signing-authority',
|
|
515
|
+
refreshId: issued.refresh!.refreshId,
|
|
516
|
+
credential: template(),
|
|
517
|
+
signingAuthority: { type: 'LearnCardSigningAuthority', ...SA },
|
|
518
|
+
})
|
|
519
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
520
|
+
await expect(
|
|
521
|
+
issue({
|
|
522
|
+
credential: {
|
|
523
|
+
...template(),
|
|
524
|
+
proof: {
|
|
525
|
+
type: 'test',
|
|
526
|
+
created: new Date().toISOString(),
|
|
527
|
+
proofPurpose: 'assertionMethod',
|
|
528
|
+
verificationMethod: issuer.learnCard.id.did(),
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
})
|
|
532
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
533
|
+
});
|
|
534
|
+
});
|
|
@@ -4,6 +4,7 @@ import { fastifyTRPCOpenApiPlugin } from 'trpc-to-openapi';
|
|
|
4
4
|
import { readFile } from 'node:fs/promises';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import type { IssueInboxCredentialType, UnsignedVC, VC } from '@learncard/types';
|
|
7
|
+
import { getBitstringStatusListEntries } from '@learncard/helpers';
|
|
7
8
|
|
|
8
9
|
import { appRouter, createContext } from '../src/app';
|
|
9
10
|
import { neogma } from '@instance';
|
|
@@ -32,6 +33,10 @@ import * as encryption from '@helpers/inbox-encryption.helpers';
|
|
|
32
33
|
import * as notifications from '@helpers/notifications.helpers';
|
|
33
34
|
import * as activity from '@helpers/activity.helpers';
|
|
34
35
|
import * as credentialStorage from '@accesslayer/credential/create';
|
|
36
|
+
import {
|
|
37
|
+
appendBitstringStatusListEntries,
|
|
38
|
+
setCredentialBitstringStatus,
|
|
39
|
+
} from '@helpers/status-list.helpers';
|
|
35
40
|
import { testUnsignedBoost } from './helpers/send';
|
|
36
41
|
import { clrMinimal } from '../../../../packages/credential-library/src/fixtures/clr/minimal';
|
|
37
42
|
|
|
@@ -154,6 +159,43 @@ describe('Universal Inbox escrow (HTTP + isolated Neo4j/Redis)', () => {
|
|
|
154
159
|
]),
|
|
155
160
|
});
|
|
156
161
|
|
|
162
|
+
const signedStatusCredential = async (): Promise<VC> =>
|
|
163
|
+
issuer.learnCard.invoke.issueCredential(
|
|
164
|
+
await appendBitstringStatusListEntries(
|
|
165
|
+
{
|
|
166
|
+
'@context': [
|
|
167
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
168
|
+
{ privateNote: 'https://schema.org/description' },
|
|
169
|
+
],
|
|
170
|
+
type: ['VerifiableCredential'],
|
|
171
|
+
id: `urn:uuid:${randomUUID()}`,
|
|
172
|
+
validFrom: new Date().toISOString(),
|
|
173
|
+
issuer: issuer.learnCard.id.did(),
|
|
174
|
+
credentialSubject: {
|
|
175
|
+
id: recipient.learnCard.id.did(),
|
|
176
|
+
privateNote: marker,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
'escrow-issuer',
|
|
180
|
+
'localhost%3A3000'
|
|
181
|
+
)
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const expectStoredStatus = async (credential: VC): Promise<void> => {
|
|
185
|
+
const entries = getBitstringStatusListEntries(credential);
|
|
186
|
+
expect(entries).toHaveLength(2);
|
|
187
|
+
const stored = await neogma.queryRunner.run(
|
|
188
|
+
'MATCH (n:Credential) RETURN n.id AS id, n.statusEntries AS statusEntries'
|
|
189
|
+
);
|
|
190
|
+
expect(stored.records).toHaveLength(1);
|
|
191
|
+
expect(JSON.parse(stored.records[0]!.get('statusEntries'))).toEqual(entries);
|
|
192
|
+
for (const purpose of ['revocation', 'suspension'] as const) {
|
|
193
|
+
expect(
|
|
194
|
+
await setCredentialBitstringStatus(stored.records[0]!.get('id'), purpose, true)
|
|
195
|
+
).toBe(true);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
157
199
|
const verifyRecipientContact = async (email: string): Promise<void> => {
|
|
158
200
|
await recipient.clients.fullAuth.contactMethods.addContactMethod({
|
|
159
201
|
type: 'email',
|
|
@@ -255,7 +297,7 @@ describe('Universal Inbox escrow (HTTP + isolated Neo4j/Redis)', () => {
|
|
|
255
297
|
name: 'Inbox concurrency test',
|
|
256
298
|
category: 'Achievement',
|
|
257
299
|
});
|
|
258
|
-
const credential = await
|
|
300
|
+
const credential = await signedStatusCredential();
|
|
259
301
|
const issued = await issue({
|
|
260
302
|
credential,
|
|
261
303
|
recipient: { type: 'email', value: 'boost-claim@example.test' },
|
|
@@ -296,6 +338,7 @@ describe('Universal Inbox escrow (HTTP + isolated Neo4j/Redis)', () => {
|
|
|
296
338
|
'MATCH (n:Credential) RETURN n.credential AS payload'
|
|
297
339
|
);
|
|
298
340
|
expect(stored.records).toHaveLength(outcome === 'success' ? 1 : 0);
|
|
341
|
+
if (outcome === 'success') await expectStoredStatus(credential);
|
|
299
342
|
const audit = await neogma.queryRunner.run(
|
|
300
343
|
'MATCH ()-[r:CLAIMED_INBOX_CREDENTIAL]->(n:InboxCredential {id: $id}) RETURN count(r) AS count',
|
|
301
344
|
{ id: issued.issuanceId }
|
|
@@ -612,7 +655,7 @@ describe('Universal Inbox escrow (HTTP + isolated Neo4j/Redis)', () => {
|
|
|
612
655
|
|
|
613
656
|
it('encrypts ordinary credential storage on auto-delivery and wipes escrow', async () => {
|
|
614
657
|
await verifyRecipientContact('existing@example.test');
|
|
615
|
-
const credential = await
|
|
658
|
+
const credential = await signedStatusCredential();
|
|
616
659
|
const escrowEncrypt = vi.spyOn(encryption, 'encryptInboxCredential');
|
|
617
660
|
const issued = await issue({
|
|
618
661
|
credential,
|
|
@@ -632,6 +675,7 @@ describe('Universal Inbox escrow (HTTP + isolated Neo4j/Redis)', () => {
|
|
|
632
675
|
const stored = JSON.parse(deliveries.records[0]!.get('payload'));
|
|
633
676
|
expect(await recipient.learnCard.invoke.decryptDagJwe(stored)).toEqual(credential);
|
|
634
677
|
expect(await issuer.learnCard.invoke.decryptDagJwe(stored)).toEqual(credential);
|
|
678
|
+
await expectStoredStatus(credential);
|
|
635
679
|
await expectNoPlaintext();
|
|
636
680
|
});
|
|
637
681
|
|
|
@@ -656,7 +700,7 @@ describe('Universal Inbox escrow (HTTP + isolated Neo4j/Redis)', () => {
|
|
|
656
700
|
isVerified: true,
|
|
657
701
|
});
|
|
658
702
|
const client = getVerifiedContactMethodClient({ contactMethod });
|
|
659
|
-
const credential = await
|
|
703
|
+
const credential = await signedStatusCredential();
|
|
660
704
|
const claimed = await client.inbox.claim({
|
|
661
705
|
credential,
|
|
662
706
|
configuration: { publishableKey: integration.publishableKey!, expiresInDays: 3 },
|
|
@@ -701,6 +745,7 @@ describe('Universal Inbox escrow (HTTP + isolated Neo4j/Redis)', () => {
|
|
|
701
745
|
JSON.parse(stored.records[0]!.get('payload'))
|
|
702
746
|
)
|
|
703
747
|
).toEqual(credential);
|
|
748
|
+
await expectStoredStatus(credential);
|
|
704
749
|
await expectNoPlaintext();
|
|
705
750
|
});
|
|
706
751
|
|