@learncard/network-brain-service 3.19.1 → 4.0.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 +44 -111
- package/AGENTS.md +52 -0
- package/CHANGELOG.md +109 -0
- package/README.md +9 -2
- package/bootstrapLambda.test.ts +72 -0
- package/config/config.dev.json +29 -0
- package/config/config.example.json +29 -0
- package/config/config.json +1 -0
- package/config/config.production.json +29 -0
- package/config/config.scouts.dev.json +24 -0
- package/config/config.scouts.production.json +24 -0
- package/contractEventsLambda.ts +18 -0
- package/contractEventsLambdaApp.ts +19 -0
- package/didWebLambda.ts +13 -18
- package/didWebLambdaApp.ts +22 -0
- package/dist/accesslayer/app-manifest-version/create.d.ts +10 -0
- package/dist/accesslayer/app-manifest-version/create.d.ts.map +1 -0
- package/dist/accesslayer/app-manifest-version/read.d.ts +11 -0
- package/dist/accesslayer/app-manifest-version/read.d.ts.map +1 -0
- package/dist/accesslayer/app-manifest-version/update.d.ts +7 -0
- package/dist/accesslayer/app-manifest-version/update.d.ts.map +1 -0
- package/dist/accesslayer/app-store-listing/read.d.ts +2 -1
- package/dist/accesslayer/app-store-listing/read.d.ts.map +1 -1
- package/dist/accesslayer/app-store-listing/relationships/create.d.ts +1 -0
- package/dist/accesslayer/app-store-listing/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/app-store-listing/relationships/read.d.ts +2 -0
- package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/app-store-listing/update.d.ts +1 -0
- package/dist/accesslayer/app-store-listing/update.d.ts.map +1 -1
- package/dist/accesslayer/boost/relationships/create.d.ts +2 -1
- package/dist/accesslayer/boost/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts +9 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/read.d.ts +34 -3
- package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +4 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/delete.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +3 -2
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts +12 -0
- package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts.map +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts +12 -0
- package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts.map +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +8 -5
- 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/relationships/create.d.ts +4 -3
- package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/relationships/create.d.ts +2 -1
- package/dist/accesslayer/inbox-credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/update.d.ts +2 -1
- package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
- package/dist/accesslayer/share-link/abandon.d.ts.map +1 -1
- package/dist/accesslayer/share-link/attachment.d.ts +37 -0
- package/dist/accesslayer/share-link/attachment.d.ts.map +1 -0
- package/dist/accesslayer/share-link/finalize.d.ts.map +1 -1
- package/dist/accesslayer/share-link/helpers.d.ts +3 -0
- package/dist/accesslayer/share-link/helpers.d.ts.map +1 -1
- package/dist/accesslayer/share-link/recovery.d.ts.map +1 -1
- package/dist/accesslayer/share-link/reserve.d.ts.map +1 -1
- package/dist/accesslayer/share-link/revoke.d.ts.map +1 -1
- package/dist/accesslayer/share-link/types.d.ts +8 -3
- package/dist/accesslayer/share-link/types.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +49 -0
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/config/applyDockerStageConfig.d.ts +2 -0
- package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
- package/dist/config/environment.d.ts +5 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
- package/dist/config/stageConfig.d.ts +112 -0
- package/dist/config/stageConfig.d.ts.map +1 -0
- package/dist/config/stageConfig.test.d.ts +2 -0
- package/dist/config/stageConfig.test.d.ts.map +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/docker-entry.d.ts.map +1 -1
- package/dist/helpers/app-manifest.helpers.d.ts +5 -0
- package/dist/helpers/app-manifest.helpers.d.ts.map +1 -0
- package/dist/helpers/claim-hooks.helpers.d.ts +2 -1
- package/dist/helpers/claim-hooks.helpers.d.ts.map +1 -1
- package/dist/helpers/consent-referral.helpers.d.ts +2 -0
- package/dist/helpers/consent-referral.helpers.d.ts.map +1 -0
- package/dist/helpers/consent-scopes.helpers.d.ts +7 -0
- package/dist/helpers/consent-scopes.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-event-maintenance.helpers.d.ts +3 -0
- package/dist/helpers/contract-event-maintenance.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-events.helpers.d.ts +25 -0
- package/dist/helpers/contract-events.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-requests.helpers.d.ts +19 -0
- package/dist/helpers/contract-requests.helpers.d.ts.map +1 -0
- package/dist/helpers/finalize-inbox.helpers.d.ts +3 -3
- package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inline-template.helpers.d.ts +45 -0
- package/dist/helpers/inline-template.helpers.d.ts.map +1 -0
- package/dist/helpers/notificationMessages.d.ts +1 -1
- package/dist/helpers/notificationMessages.d.ts.map +1 -1
- package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
- package/dist/helpers/notifications.helpers.d.ts +6 -0
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/profile.helpers.d.ts +1 -0
- package/dist/helpers/profile.helpers.d.ts.map +1 -1
- package/dist/helpers/share-link-attachment-access.d.ts +4 -0
- package/dist/helpers/share-link-attachment-access.d.ts.map +1 -0
- package/dist/helpers/share-link-attachment.d.ts +18 -0
- package/dist/helpers/share-link-attachment.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/attachment-stat.d.ts +5 -0
- package/dist/helpers/share-link-coordinator/attachment-stat.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -1
- package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/runner.d.ts +1 -0
- package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/runtime.d.ts +2 -0
- package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/types.d.ts +4 -1
- package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -1
- package/dist/helpers/share-link-owner-projection.d.ts.map +1 -1
- package/dist/helpers/share-link-policy/production.d.ts +3 -5
- package/dist/helpers/share-link-policy/production.d.ts.map +1 -1
- package/dist/helpers/share-link-policy/resolver.d.ts +1 -1
- package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -1
- package/dist/helpers/share-link-policy/types.d.ts +8 -2
- package/dist/helpers/share-link-policy/types.d.ts.map +1 -1
- package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
- package/dist/helpers/smartResume.helpers.d.ts +3 -0
- package/dist/helpers/smartResume.helpers.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/AppManifestVersion.d.ts +20 -0
- package/dist/models/AppManifestVersion.d.ts.map +1 -0
- package/dist/models/AppStoreListing.d.ts +13 -0
- package/dist/models/AppStoreListing.d.ts.map +1 -1
- package/dist/models/ConsentFlowContract.d.ts +13 -2
- package/dist/models/ConsentFlowContract.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/Integration.d.ts +2 -0
- package/dist/models/Integration.d.ts.map +1 -1
- package/dist/models/ShareLink.d.ts +2 -0
- package/dist/models/ShareLink.d.ts.map +1 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/share-link-constraints.d.ts.map +1 -1
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/routes/app-store.d.ts +619 -0
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +2 -2
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +146 -3
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +34 -0
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +52 -2
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/public-share-links.d.ts +55 -0
- package/dist/routes/public-share-links.d.ts.map +1 -1
- package/dist/routes/share-links.d.ts +175 -14
- package/dist/routes/share-links.d.ts.map +1 -1
- package/dist/routes/test.d.ts +5 -0
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/types/app-manifest.d.ts +339 -0
- package/dist/types/app-manifest.d.ts.map +1 -0
- package/dist/types/app-store-listing.d.ts +34 -0
- package/dist/types/app-store-listing.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +30 -3
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +1 -1
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +49 -195
- package/lambdaApp.ts +203 -0
- package/package.json +20 -18
- package/scripts/export-openapi.ts +20 -0
- package/scripts/offline-schema-environment.ts +18 -0
- package/scripts/verify-python-contracts.ts +72 -0
- package/serverless-local.yml +3 -6
- package/serverless.function-env.cjs +50 -0
- package/serverless.local.yml +0 -1
- package/serverless.yml +27 -74
- package/shareLinkMaintenanceLambda.ts +13 -44
- package/shareLinkMaintenanceLambdaApp.ts +50 -0
- package/src/accesslayer/app-manifest-version/create.ts +57 -0
- package/src/accesslayer/app-manifest-version/read.ts +129 -0
- package/src/accesslayer/app-manifest-version/update.ts +61 -0
- package/src/accesslayer/app-store-listing/read.ts +6 -0
- package/src/accesslayer/app-store-listing/relationships/create.ts +20 -0
- package/src/accesslayer/app-store-listing/relationships/read.ts +24 -1
- package/src/accesslayer/app-store-listing/update.ts +15 -0
- package/src/accesslayer/boost/relationships/create.ts +8 -2
- package/src/accesslayer/consentflowcontract/create.ts +81 -8
- package/src/accesslayer/consentflowcontract/read.ts +84 -10
- package/src/accesslayer/consentflowcontract/relationships/create.ts +116 -93
- package/src/accesslayer/consentflowcontract/relationships/delete.ts +1 -0
- package/src/accesslayer/consentflowcontract/relationships/read.ts +184 -108
- package/src/accesslayer/consentflowcontract/relationships/recipients.ts +125 -0
- package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +125 -0
- package/src/accesslayer/consentflowcontract/relationships/update.ts +240 -189
- package/src/accesslayer/credential/create.test.ts +2 -115
- package/src/accesslayer/credential/create.ts +11 -6
- package/src/accesslayer/credential/relationships/create.ts +17 -10
- package/src/accesslayer/inbox-credential/relationships/create.ts +70 -48
- package/src/accesslayer/inbox-credential/update.ts +4 -2
- package/src/accesslayer/share-link/abandon.ts +20 -0
- package/src/accesslayer/share-link/attachment.ts +360 -0
- package/src/accesslayer/share-link/finalize.ts +55 -7
- package/src/accesslayer/share-link/helpers.ts +33 -2
- package/src/accesslayer/share-link/recovery.ts +20 -0
- package/src/accesslayer/share-link/reserve.ts +85 -6
- package/src/accesslayer/share-link/revoke.ts +35 -0
- package/src/accesslayer/share-link/types.ts +9 -4
- package/src/cache/app-store.caches.ts +35 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/environment.test.ts +17 -12
- package/src/config/environment.ts +1 -0
- package/src/config/serverlessFunctionEnv.test.ts +105 -0
- package/src/config/stageConfig.test.ts +25 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +24 -0
- package/src/helpers/app-manifest.helpers.ts +185 -0
- package/src/helpers/claim-hooks.helpers.ts +20 -7
- package/src/helpers/consent-referral.helpers.ts +6 -0
- package/src/helpers/consent-scopes.helpers.ts +58 -0
- package/src/helpers/contract-event-maintenance.helpers.ts +43 -0
- package/src/helpers/contract-events.helpers.ts +540 -0
- package/src/helpers/contract-requests.helpers.ts +211 -0
- package/src/helpers/finalize-inbox.helpers.ts +163 -44
- package/src/helpers/inbox.helpers.ts +51 -66
- package/src/helpers/inline-template.helpers.ts +560 -0
- package/src/helpers/notificationMessages.ts +59 -0
- package/src/helpers/notificationQueue.helpers.ts +23 -5
- package/src/helpers/notifications.helpers.ts +25 -1
- package/src/helpers/profile.helpers.ts +6 -0
- package/src/helpers/share-link-attachment-access.ts +87 -0
- package/src/helpers/share-link-attachment.ts +115 -0
- package/src/helpers/share-link-coordinator/attachment-stat.ts +41 -0
- package/src/helpers/share-link-coordinator/coordinator.ts +15 -2
- package/src/helpers/share-link-coordinator/recovery-runner.ts +15 -0
- package/src/helpers/share-link-maintenance/runner.ts +28 -0
- package/src/helpers/share-link-maintenance/runtime.ts +6 -0
- package/src/helpers/share-link-maintenance/telemetry.ts +3 -0
- package/src/helpers/share-link-maintenance/types.ts +2 -0
- package/src/helpers/share-link-owner-projection.ts +2 -0
- package/src/helpers/share-link-policy/production.ts +37 -17
- package/src/helpers/share-link-policy/resolver.ts +16 -11
- package/src/helpers/share-link-policy/types.ts +15 -7
- package/src/helpers/signingAuthority.helpers.ts +23 -14
- package/src/helpers/smartResume.helpers.ts +123 -0
- package/src/models/AppManifestVersion.ts +43 -0
- package/src/models/AppStoreListing.ts +32 -0
- package/src/models/ConsentFlowContract.ts +34 -3
- package/src/models/ConsentFlowTerms.ts +10 -0
- package/src/models/ConsentFlowTransaction.ts +3 -0
- package/src/models/Integration.ts +2 -0
- package/src/models/ShareLink.ts +4 -0
- package/src/models/index.ts +23 -0
- package/src/models/share-link-constraints.ts +5 -0
- package/src/openapi.ts +44 -8
- package/src/routes/app-store.ts +1054 -27
- package/src/routes/boosts.ts +12 -14
- package/src/routes/contracts.ts +382 -262
- package/src/routes/inbox.ts +4 -2
- package/src/routes/index.ts +4 -1
- package/src/routes/profile-manager.ts +19 -0
- package/src/routes/profiles.ts +12 -0
- package/src/routes/public-share-links.ts +169 -0
- package/src/routes/share-links.ts +69 -0
- package/src/routes/workflows.ts +3 -2
- package/src/types/app-manifest.ts +28 -0
- package/src/types/app-store-listing.ts +13 -0
- package/src/types/consentflowcontract.ts +25 -4
- package/test/app-manifest.helpers.spec.ts +170 -0
- package/test/app-manifest.routes.spec.ts +299 -0
- package/test/app-store-listing-updates.spec.ts +236 -0
- package/test/app-store-listing.spec.ts +65 -3
- package/test/consent-data-boundaries.spec.ts +352 -0
- package/test/consent-recipients.spec.ts +513 -0
- package/test/consent-scopes.helpers.spec.ts +67 -0
- package/test/consentflow.spec.ts +63 -2
- package/test/contract-requests.spec.ts +1588 -0
- package/test/credential-storage.neo4j.spec.ts +122 -0
- package/test/guardian-gated-routes.spec.ts +36 -0
- package/test/inbox-security.e2e.ts +26 -10
- package/test/inbox.spec.ts +184 -8
- package/test/inline-template.helpers.spec.ts +49 -0
- package/test/managed-recipient-inbox.spec.ts +24 -2
- package/test/openapi.spec.ts +14 -73
- package/test/profiles.spec.ts +30 -0
- package/test/share-link-attachment.neo4j.spec.ts +262 -0
- package/test/share-link-attachment.unit.spec.ts +244 -0
- package/test/share-link-lifecycle.neo4j.spec.ts +145 -2
- package/test/share-link-maintenance.unit.spec.ts +32 -5
- package/test/share-link-owner-route.unit.spec.ts +70 -2
- package/test/share-link-policy.unit.spec.ts +18 -4
- package/test/share-link-production-policy.unit.spec.ts +58 -9
- package/test/share-link-public-route.unit.spec.ts +279 -0
- package/test/share-link-service-policy.neo4j.spec.ts +403 -0
- package/test/signing-authority-encryption.spec.ts +75 -0
- package/test/smart-resume-consent.spec.ts +233 -0
- package/test/smart-resume-upload.spec.ts +65 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +13 -0
- package/vitest.inbox-e2e.config.ts +2 -0
- package/workerLambda.test.ts +100 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { JWEValidator, VCValidator, type UnsignedVC } from '@learncard/types';
|
|
3
|
+
import { getLearnCard } from '@helpers/learnCard.helpers';
|
|
4
|
+
import { issueCredentialWithSigningAuthority } from '@helpers/signingAuthority.helpers';
|
|
5
|
+
import type { CredentialIssuer } from 'types/issuer';
|
|
6
|
+
import type { SigningAuthorityForUserType } from 'types/profile';
|
|
7
|
+
|
|
8
|
+
const issuer = {
|
|
9
|
+
type: 'profile',
|
|
10
|
+
profile: { profileId: 'signer-owner' },
|
|
11
|
+
} as unknown as CredentialIssuer;
|
|
12
|
+
const authority = {
|
|
13
|
+
signingAuthority: { endpoint: 'https://sa.example' },
|
|
14
|
+
relationship: { name: 'test', did: 'did:example:authority' },
|
|
15
|
+
} as unknown as SigningAuthorityForUserType;
|
|
16
|
+
const unsignedFor = (subjectDid: string): UnsignedVC => ({
|
|
17
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
18
|
+
type: ['VerifiableCredential'],
|
|
19
|
+
issuer: 'did:example:issuer',
|
|
20
|
+
validFrom: '2026-01-01T00:00:00Z',
|
|
21
|
+
name: 'Private signer credential',
|
|
22
|
+
credentialSubject: { id: subjectDid },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('test signing-authority encryption', () => {
|
|
26
|
+
it('encrypts for subject, owner, and delegated readers but not outsiders or the brain signer', async () => {
|
|
27
|
+
const subject = await getLearnCard('1'.repeat(64));
|
|
28
|
+
const owner = await getLearnCard('2'.repeat(64));
|
|
29
|
+
const delegate = await getLearnCard('3'.repeat(64));
|
|
30
|
+
const outsider = await getLearnCard('4'.repeat(64));
|
|
31
|
+
const brain = await getLearnCard();
|
|
32
|
+
const issued = await issueCredentialWithSigningAuthority(
|
|
33
|
+
issuer,
|
|
34
|
+
unsignedFor(subject.id.did()),
|
|
35
|
+
authority,
|
|
36
|
+
'network.example',
|
|
37
|
+
true,
|
|
38
|
+
owner.id.did(),
|
|
39
|
+
false,
|
|
40
|
+
[delegate.id.did()]
|
|
41
|
+
);
|
|
42
|
+
const jwe = JWEValidator.parse(issued.credential);
|
|
43
|
+
const decrypted = await subject.invoke.decryptDagJwe(jwe);
|
|
44
|
+
expect(decrypted).toMatchObject({
|
|
45
|
+
name: 'Private signer credential',
|
|
46
|
+
credentialSubject: { id: subject.id.did() },
|
|
47
|
+
});
|
|
48
|
+
expect(await owner.invoke.decryptDagJwe(jwe)).toEqual(decrypted);
|
|
49
|
+
expect(await delegate.invoke.decryptDagJwe(jwe)).toEqual(decrypted);
|
|
50
|
+
expect(await outsider.invoke.decryptDagJwe(jwe).catch(() => undefined)).toBeFalsy();
|
|
51
|
+
expect(await brain.invoke.decryptDagJwe(jwe).catch(() => undefined)).toBeFalsy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('preserves signed status coordinates when explicitly opting out of encryption', async () => {
|
|
55
|
+
const subject = await getLearnCard('6'.repeat(64));
|
|
56
|
+
const status = {
|
|
57
|
+
id: 'https://network.example/status/1#7',
|
|
58
|
+
type: 'BitstringStatusListEntry' as const,
|
|
59
|
+
statusPurpose: 'revocation' as const,
|
|
60
|
+
statusListIndex: '7',
|
|
61
|
+
statusListCredential: 'https://network.example/status/1',
|
|
62
|
+
};
|
|
63
|
+
const issued = await issueCredentialWithSigningAuthority(
|
|
64
|
+
issuer,
|
|
65
|
+
{ ...unsignedFor(subject.id.did()), credentialStatus: status },
|
|
66
|
+
authority,
|
|
67
|
+
'network.example',
|
|
68
|
+
false,
|
|
69
|
+
undefined,
|
|
70
|
+
false
|
|
71
|
+
);
|
|
72
|
+
expect(VCValidator.parse(issued.credential).credentialStatus).toEqual(status);
|
|
73
|
+
expect(issued.statusEntries).toEqual([status]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { environment } from '@environment';
|
|
4
|
+
import { ConsentFlowTerms } from '@models';
|
|
5
|
+
import { neogma } from '@instance';
|
|
6
|
+
import { getUser } from './helpers/getClient';
|
|
7
|
+
import { normalContract, normalFullTerms } from './helpers/contract';
|
|
8
|
+
import { getContractTermsForProfile } from '@accesslayer/consentflowcontract/relationships/read';
|
|
9
|
+
import { getContractDetailsByUri } from '@accesslayer/consentflowcontract/relationships/read';
|
|
10
|
+
import { uploadSmartResume } from '@helpers/smartResume.helpers';
|
|
11
|
+
|
|
12
|
+
vi.mock('@helpers/smartResume.helpers', () => ({ uploadSmartResume: vi.fn() }));
|
|
13
|
+
const upload = vi.mocked(uploadSmartResume);
|
|
14
|
+
|
|
15
|
+
describe('SmartResume consent before publication', () => {
|
|
16
|
+
let owner: Awaited<ReturnType<typeof getUser>>;
|
|
17
|
+
let learner: Awaited<ReturnType<typeof getUser>>;
|
|
18
|
+
let recipient: Awaited<ReturnType<typeof getUser>>;
|
|
19
|
+
let contractUri: string;
|
|
20
|
+
let recipientId: string;
|
|
21
|
+
const originalUri = environment.SMART_RESUME_CONTRACT_URI;
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
upload.mockReset().mockResolvedValue('https://example.com/resume');
|
|
24
|
+
[owner, learner, recipient] = await Promise.all([
|
|
25
|
+
getUser(randomBytes(32).toString('hex')),
|
|
26
|
+
getUser(randomBytes(32).toString('hex')),
|
|
27
|
+
getUser(randomBytes(32).toString('hex')),
|
|
28
|
+
]);
|
|
29
|
+
for (const [i, actor] of [owner, learner, recipient].entries())
|
|
30
|
+
await actor.clients.fullAuth.profile.createProfile({
|
|
31
|
+
profileId: `resume-${i}-${randomBytes(5).toString('hex')}`,
|
|
32
|
+
});
|
|
33
|
+
recipientId = (await recipient.clients.fullAuth.profile.getProfile())!.profileId;
|
|
34
|
+
contractUri = await owner.clients.fullAuth.contracts.createConsentFlowContract({
|
|
35
|
+
name: 'Synthetic SmartResume',
|
|
36
|
+
contract: normalContract,
|
|
37
|
+
recipients: [recipientId],
|
|
38
|
+
});
|
|
39
|
+
environment.SMART_RESUME_CONTRACT_URI = contractUri;
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
environment.SMART_RESUME_CONTRACT_URI = originalUri;
|
|
43
|
+
});
|
|
44
|
+
const accept = (overrides: Record<string, unknown> = {}) =>
|
|
45
|
+
learner.clients.fullAuth.contracts.consentToContract({
|
|
46
|
+
contractUri,
|
|
47
|
+
terms: normalFullTerms,
|
|
48
|
+
audienceVersion: 1,
|
|
49
|
+
recipientToken: 'synthetic-recipient-token',
|
|
50
|
+
...overrides,
|
|
51
|
+
});
|
|
52
|
+
const savedTerms = async () => {
|
|
53
|
+
const details = (await getContractDetailsByUri(contractUri))!;
|
|
54
|
+
return getContractTermsForProfile(
|
|
55
|
+
(await learner.clients.fullAuth.profile.getProfile())!,
|
|
56
|
+
details.contract
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
it.each([undefined, 0])(
|
|
61
|
+
'does not publish a rejected audience version %s',
|
|
62
|
+
async audienceVersion => {
|
|
63
|
+
await expect(accept({ audienceVersion })).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
64
|
+
expect(upload).not.toHaveBeenCalled();
|
|
65
|
+
expect(await savedTerms()).toBeFalsy();
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
it('commits consent before calling the external uploader and caches a completed retry', async () => {
|
|
69
|
+
upload.mockImplementationOnce(async terms => {
|
|
70
|
+
expect((await savedTerms())?.status).toBe('live');
|
|
71
|
+
expect(terms).toEqual(normalFullTerms);
|
|
72
|
+
return 'https://example.com/resume';
|
|
73
|
+
});
|
|
74
|
+
const result = await accept();
|
|
75
|
+
expect(await accept()).toEqual(result);
|
|
76
|
+
expect(upload).toHaveBeenCalledTimes(1);
|
|
77
|
+
expect((await savedTerms())?.smartResumePublicationStatus).toBe('succeeded');
|
|
78
|
+
});
|
|
79
|
+
it('retains consent on upload failure and retries the same decision without consenting again', async () => {
|
|
80
|
+
upload.mockRejectedValueOnce(new Error('Synthetic upstream outage'));
|
|
81
|
+
await expect(accept()).rejects.toMatchObject({ code: 'BAD_GATEWAY' });
|
|
82
|
+
const failed = (await savedTerms())!;
|
|
83
|
+
expect(failed.status).toBe('live');
|
|
84
|
+
expect(failed.smartResumePublicationStatus).toBe('failed');
|
|
85
|
+
await expect(accept({ recipientToken: 'different-token' })).rejects.toMatchObject({
|
|
86
|
+
code: 'CONFLICT',
|
|
87
|
+
});
|
|
88
|
+
const result = await accept();
|
|
89
|
+
expect(result.termsUri).toContain(failed.id);
|
|
90
|
+
const history = await learner.clients.fullAuth.contracts.getTermsTransactionHistory({
|
|
91
|
+
uri: result.termsUri,
|
|
92
|
+
});
|
|
93
|
+
expect(history.records).toHaveLength(1);
|
|
94
|
+
expect(upload).toHaveBeenCalledTimes(2);
|
|
95
|
+
});
|
|
96
|
+
it('rejects a failed-upload retry after the audience changes', async () => {
|
|
97
|
+
upload.mockRejectedValueOnce(new Error('Synthetic upstream outage'));
|
|
98
|
+
await expect(accept()).rejects.toMatchObject({ code: 'BAD_GATEWAY' });
|
|
99
|
+
await owner.clients.fullAuth.contracts.removeContractRecipient({
|
|
100
|
+
contractUri,
|
|
101
|
+
recipient: recipientId,
|
|
102
|
+
});
|
|
103
|
+
await expect(accept()).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
104
|
+
expect(upload).toHaveBeenCalledTimes(1);
|
|
105
|
+
});
|
|
106
|
+
it('rejects an audience removal between reading the contract and committing consent', async () => {
|
|
107
|
+
const reads = await import('@accesslayer/consentflowcontract/relationships/read');
|
|
108
|
+
const original = reads.getContractDetailsByUri;
|
|
109
|
+
const spy = vi.spyOn(reads, 'getContractDetailsByUri').mockImplementationOnce(async uri => {
|
|
110
|
+
const details = await original(uri);
|
|
111
|
+
await owner.clients.fullAuth.contracts.removeContractRecipient({
|
|
112
|
+
contractUri,
|
|
113
|
+
recipient: recipientId,
|
|
114
|
+
});
|
|
115
|
+
return details;
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
await expect(accept()).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
119
|
+
expect(upload).not.toHaveBeenCalled();
|
|
120
|
+
expect(await savedTerms()).toBeFalsy();
|
|
121
|
+
} finally {
|
|
122
|
+
spy.mockRestore();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
it('recovers a publication lease left behind by an interrupted process', async () => {
|
|
126
|
+
upload.mockRejectedValueOnce(new Error('Synthetic outage'));
|
|
127
|
+
await expect(accept()).rejects.toMatchObject({ code: 'BAD_GATEWAY' });
|
|
128
|
+
const terms = (await savedTerms())!;
|
|
129
|
+
await ConsentFlowTerms.update(
|
|
130
|
+
{ smartResumePublicationStatus: 'sending', smartResumeLeaseUntil: Date.now() - 1 },
|
|
131
|
+
{ where: { id: terms.id } }
|
|
132
|
+
);
|
|
133
|
+
await expect(accept()).resolves.toHaveProperty('redirectUrl', 'https://example.com/resume');
|
|
134
|
+
expect(upload).toHaveBeenCalledTimes(2);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('ties a referral upload retry to the accepted invitation', async () => {
|
|
138
|
+
const profileId = (await learner.clients.fullAuth.profile.getProfile())!.profileId;
|
|
139
|
+
await owner.clients.fullAuth.contracts.sendContractRequest({
|
|
140
|
+
contractUri,
|
|
141
|
+
targetProfileId: profileId,
|
|
142
|
+
});
|
|
143
|
+
const invitation = (await owner.clients.fullAuth.contracts.getRequestStatusForProfile({
|
|
144
|
+
contractUri,
|
|
145
|
+
targetProfileId: profileId,
|
|
146
|
+
}))!;
|
|
147
|
+
upload.mockRejectedValueOnce(new Error('Synthetic outage'));
|
|
148
|
+
await expect(accept({ expectedRequestId: invitation.requestId })).rejects.toMatchObject({
|
|
149
|
+
code: 'BAD_GATEWAY',
|
|
150
|
+
});
|
|
151
|
+
await expect(accept({ expectedRequestId: 'different-invitation' })).rejects.toMatchObject({
|
|
152
|
+
code: 'CONFLICT',
|
|
153
|
+
});
|
|
154
|
+
await expect(accept({ expectedRequestId: invitation.requestId })).resolves.toHaveProperty(
|
|
155
|
+
'redirectUrl'
|
|
156
|
+
);
|
|
157
|
+
await neogma.queryRunner.run(
|
|
158
|
+
'MATCH (c:ConsentFlowContract {id: $contractId})-[r:REQUESTED_FOR]->(:Profile {profileId: $profileId}) SET r.requestId = $replacement',
|
|
159
|
+
{
|
|
160
|
+
contractId: contractUri.split(':').at(-1),
|
|
161
|
+
profileId,
|
|
162
|
+
replacement: 'replacement-invitation',
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
await expect(accept({ expectedRequestId: invitation.requestId })).rejects.toMatchObject({
|
|
166
|
+
code: 'CONFLICT',
|
|
167
|
+
});
|
|
168
|
+
expect(upload).toHaveBeenCalledTimes(2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('allows only one uploader while a publication is in flight', async () => {
|
|
172
|
+
let release!: () => void;
|
|
173
|
+
let started!: () => void;
|
|
174
|
+
const entered = new Promise<void>(resolve => {
|
|
175
|
+
started = resolve;
|
|
176
|
+
});
|
|
177
|
+
upload.mockImplementationOnce(async () => {
|
|
178
|
+
started();
|
|
179
|
+
await new Promise<void>(resolve => {
|
|
180
|
+
release = resolve;
|
|
181
|
+
});
|
|
182
|
+
return 'https://example.com/resume';
|
|
183
|
+
});
|
|
184
|
+
const first = accept();
|
|
185
|
+
await entered;
|
|
186
|
+
await expect(accept()).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
187
|
+
release();
|
|
188
|
+
await first;
|
|
189
|
+
expect(upload).toHaveBeenCalledTimes(1);
|
|
190
|
+
});
|
|
191
|
+
it('retries a one-time upload without creating a second consent', async () => {
|
|
192
|
+
upload.mockRejectedValueOnce(new Error('Synthetic outage'));
|
|
193
|
+
await expect(accept({ oneTime: true })).rejects.toMatchObject({ code: 'BAD_GATEWAY' });
|
|
194
|
+
const result = await accept({ oneTime: true });
|
|
195
|
+
expect((await savedTerms())?.status).toBe('stale');
|
|
196
|
+
expect(
|
|
197
|
+
(
|
|
198
|
+
await learner.clients.fullAuth.contracts.getTermsTransactionHistory({
|
|
199
|
+
uri: result.termsUri,
|
|
200
|
+
})
|
|
201
|
+
).records
|
|
202
|
+
).toHaveLength(1);
|
|
203
|
+
});
|
|
204
|
+
it('allows a new one-time decision after a completed one-time upload', async () => {
|
|
205
|
+
const first = await accept({ oneTime: true });
|
|
206
|
+
const terms = structuredClone(normalFullTerms);
|
|
207
|
+
terms.read.personal.name = 'Updated synthetic name';
|
|
208
|
+
const second = await accept({ oneTime: true, terms });
|
|
209
|
+
expect(second.termsUri).toBe(first.termsUri);
|
|
210
|
+
expect(upload).toHaveBeenCalledTimes(2);
|
|
211
|
+
expect(
|
|
212
|
+
(
|
|
213
|
+
await learner.clients.fullAuth.contracts.getTermsTransactionHistory({
|
|
214
|
+
uri: second.termsUri,
|
|
215
|
+
})
|
|
216
|
+
).records
|
|
217
|
+
).toHaveLength(2);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('does not publish a retry after terms were changed or expired', async () => {
|
|
221
|
+
upload.mockRejectedValueOnce(new Error('Synthetic outage'));
|
|
222
|
+
await expect(accept()).rejects.toMatchObject({ code: 'BAD_GATEWAY' });
|
|
223
|
+
const terms = (await savedTerms())!;
|
|
224
|
+
await ConsentFlowTerms.update({ mutationVersion: 1 }, { where: { id: terms.id } });
|
|
225
|
+
await expect(accept()).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
226
|
+
await ConsentFlowTerms.update(
|
|
227
|
+
{ mutationVersion: 0, expiresAt: '2000-01-01T00:00:00Z' },
|
|
228
|
+
{ where: { id: terms.id } }
|
|
229
|
+
);
|
|
230
|
+
await expect(accept()).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
231
|
+
expect(upload).toHaveBeenCalledTimes(1);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
2
|
+
import { uploadSmartResume } from '@helpers/smartResume.helpers';
|
|
3
|
+
import { resolveUri } from '@helpers/uri.helpers';
|
|
4
|
+
import { normalFullTerms } from './helpers/contract';
|
|
5
|
+
|
|
6
|
+
vi.mock('@helpers/uri.helpers', () => ({ resolveUri: vi.fn() }));
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.unstubAllGlobals();
|
|
9
|
+
vi.resetAllMocks();
|
|
10
|
+
});
|
|
11
|
+
it('publishes only selected fields and deduplicated credentials with the existing SmartResume payload', async () => {
|
|
12
|
+
const terms = structuredClone(normalFullTerms);
|
|
13
|
+
terms.read.credentials.categories = {
|
|
14
|
+
Achievement: { shared: ['urn:synthetic', 'urn:synthetic'] },
|
|
15
|
+
};
|
|
16
|
+
terms.read.personal = {
|
|
17
|
+
name: 'Synthetic Learner',
|
|
18
|
+
email: 'synthetic@example.com',
|
|
19
|
+
ignored: 'private',
|
|
20
|
+
};
|
|
21
|
+
vi.mocked(resolveUri).mockResolvedValue({
|
|
22
|
+
id: 'urn:synthetic',
|
|
23
|
+
boostCredential: {
|
|
24
|
+
issuer: 'did:example:issuer',
|
|
25
|
+
credentialSubject: { id: 'did:example:learner' },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const fetch = vi
|
|
29
|
+
.fn()
|
|
30
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'synthetic-token' })))
|
|
31
|
+
.mockResolvedValueOnce(
|
|
32
|
+
new Response(JSON.stringify({ redirect_url: 'https://example.com/resume' }))
|
|
33
|
+
);
|
|
34
|
+
vi.stubGlobal('fetch', fetch);
|
|
35
|
+
expect(await uploadSmartResume(terms, 'did:example:learner', 'synthetic-recipient')).toBe(
|
|
36
|
+
'https://example.com/resume'
|
|
37
|
+
);
|
|
38
|
+
expect(resolveUri).toHaveBeenCalledOnce();
|
|
39
|
+
const payload = JSON.parse(fetch.mock.calls[1][1].body);
|
|
40
|
+
expect(payload.recipient).toEqual({
|
|
41
|
+
id: 'did:example:learner',
|
|
42
|
+
givenName: 'Synthetic Learner',
|
|
43
|
+
familyName: '',
|
|
44
|
+
email: 'synthetic@example.com',
|
|
45
|
+
});
|
|
46
|
+
expect(payload.credentials).toEqual([
|
|
47
|
+
{
|
|
48
|
+
id: 'urn:synthetic',
|
|
49
|
+
issuer: { id: 'did:example:issuer' },
|
|
50
|
+
credentialSubject: { id: 'did:example:learner' },
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
expect(JSON.stringify(payload)).not.toContain('private');
|
|
54
|
+
});
|
|
55
|
+
it('does not publish a partial selection when credential resolution fails', async () => {
|
|
56
|
+
vi.mocked(resolveUri).mockRejectedValue(new Error('Synthetic resolution failure'));
|
|
57
|
+
const fetch = vi
|
|
58
|
+
.fn()
|
|
59
|
+
.mockResolvedValue(new Response(JSON.stringify({ access_token: 'synthetic-token' })));
|
|
60
|
+
vi.stubGlobal('fetch', fetch);
|
|
61
|
+
await expect(
|
|
62
|
+
uploadSmartResume(normalFullTerms, 'did:example:learner', 'synthetic-recipient')
|
|
63
|
+
).rejects.toThrow('Synthetic resolution failure');
|
|
64
|
+
expect(fetch).toHaveBeenCalledOnce();
|
|
65
|
+
});
|
package/tsconfig.build.json
CHANGED
package/tsconfig.json
CHANGED
package/vitest.config.ts
CHANGED
|
@@ -9,10 +9,19 @@ const brainServicePreset = {
|
|
|
9
9
|
|
|
10
10
|
export default createVitestConfig(brainServicePreset, {
|
|
11
11
|
test: {
|
|
12
|
+
env: {
|
|
13
|
+
IS_E2E_TEST: 'false',
|
|
14
|
+
// The unit router tests import the complete model registry. Keep
|
|
15
|
+
// its eager index setup off; database tests have a separate config.
|
|
16
|
+
NEO4J_SKIP_INDICES: 'true',
|
|
17
|
+
},
|
|
12
18
|
include: [
|
|
19
|
+
'test/share-link-*.unit.spec.ts',
|
|
20
|
+
'test/share-content-client.*.spec.ts',
|
|
13
21
|
'test/uri-helpers.spec.ts',
|
|
14
22
|
'test/oidc-jwt.spec.ts',
|
|
15
23
|
'test/notificationMessages.spec.ts',
|
|
24
|
+
'test/openapi.spec.ts',
|
|
16
25
|
'src/helpers/posthog.helpers.test.ts',
|
|
17
26
|
'src/helpers/rateLimit.helpers.test.ts',
|
|
18
27
|
'src/helpers/credentialSubject.helpers.test.ts',
|
|
@@ -27,6 +36,10 @@ export default createVitestConfig(brainServicePreset, {
|
|
|
27
36
|
'src/helpers/credential-refresh-materiality.helpers.test.ts',
|
|
28
37
|
'src/helpers/credential-refresh-notification-policy.helpers.test.ts',
|
|
29
38
|
'src/config/environment.test.ts',
|
|
39
|
+
'src/config/stageConfig.test.ts',
|
|
40
|
+
'src/config/serverlessFunctionEnv.test.ts',
|
|
41
|
+
'bootstrapLambda.test.ts',
|
|
42
|
+
'workerLambda.test.ts',
|
|
30
43
|
'src/credential-refresh.test.ts',
|
|
31
44
|
'src/accesslayer/credential-refresh/read.test.ts',
|
|
32
45
|
'src/models/credential-refresh-constraints.test.ts',
|
|
@@ -12,6 +12,8 @@ export default createVitestConfig(serviceIntegrationPreset, {
|
|
|
12
12
|
'test/inbox-security.e2e.ts',
|
|
13
13
|
'test/inbox-batch.spec.ts',
|
|
14
14
|
'test/atomic-counter.e2e.ts',
|
|
15
|
+
'test/credential-storage.neo4j.spec.ts',
|
|
16
|
+
'test/inbox.spec.ts',
|
|
15
17
|
],
|
|
16
18
|
alias: { '@instance': require.resolve('./test/helpers/mock-instance.ts') },
|
|
17
19
|
env: {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { APIGatewayProxyEventV2, Context } from 'aws-lambda';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
bootstrap: vi.fn(),
|
|
6
|
+
getApp: vi.fn(),
|
|
7
|
+
contractEvents: vi.fn(),
|
|
8
|
+
shareLink: vi.fn(),
|
|
9
|
+
didWeb: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('@learncard/service-config', () => ({ bootstrapLambda: mocks.bootstrap }));
|
|
13
|
+
vi.mock('./src/config/stageConfig', () => ({ base: {}, stages: { dev: {}, production: {} } }));
|
|
14
|
+
vi.mock('./contractEventsLambdaApp', () => ({ contractEventsHandler: mocks.contractEvents }));
|
|
15
|
+
vi.mock('./shareLinkMaintenanceLambdaApp', () => ({
|
|
16
|
+
shareLinkMaintenanceHandler: mocks.shareLink,
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('./didWebLambdaApp', () => ({ didWebHandler: mocks.didWeb }));
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.resetModules();
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
mocks.bootstrap.mockReturnValue(mocks.getApp);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe.each([
|
|
27
|
+
{
|
|
28
|
+
name: 'contractEventsLambda',
|
|
29
|
+
entry: './contractEventsLambda',
|
|
30
|
+
handlerName: 'contractEventsHandler',
|
|
31
|
+
appHandler: mocks.contractEvents,
|
|
32
|
+
appModule: './contractEventsLambdaApp',
|
|
33
|
+
result: { status: 'ok' },
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'shareLinkMaintenanceLambda',
|
|
37
|
+
entry: './shareLinkMaintenanceLambda',
|
|
38
|
+
handlerName: 'shareLinkMaintenanceHandler',
|
|
39
|
+
appHandler: mocks.shareLink,
|
|
40
|
+
appModule: './shareLinkMaintenanceLambdaApp',
|
|
41
|
+
result: { status: 'idle' },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'didWebLambda',
|
|
45
|
+
entry: './didWebLambda',
|
|
46
|
+
handlerName: 'didWebHandler',
|
|
47
|
+
appHandler: mocks.didWeb,
|
|
48
|
+
appModule: './didWebLambdaApp',
|
|
49
|
+
result: { statusCode: 200 },
|
|
50
|
+
},
|
|
51
|
+
])('$name thin bootstrap entry', ({ entry, handlerName, appHandler, appModule, result }) => {
|
|
52
|
+
it('bootstraps once with checked-in stage config and the deploy stage', async () => {
|
|
53
|
+
vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', 'lcn-brain-service-production-worker');
|
|
54
|
+
vi.stubEnv('LAMBDA_STAGE', 'production');
|
|
55
|
+
await import(entry);
|
|
56
|
+
expect(mocks.bootstrap).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(mocks.bootstrap).toHaveBeenCalledWith({
|
|
58
|
+
base: {},
|
|
59
|
+
stages: { dev: {}, production: {} },
|
|
60
|
+
stage: 'production',
|
|
61
|
+
importApp: expect.any(Function),
|
|
62
|
+
});
|
|
63
|
+
vi.unstubAllEnvs();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('uses CONFIG_STAGE outside Lambda rather than LAMBDA_STAGE', async () => {
|
|
67
|
+
vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', '');
|
|
68
|
+
vi.stubEnv('LAMBDA_STAGE', 'production');
|
|
69
|
+
vi.stubEnv('CONFIG_STAGE', 'dev');
|
|
70
|
+
await import(entry);
|
|
71
|
+
expect(mocks.bootstrap).toHaveBeenCalledWith(expect.objectContaining({ stage: 'dev' }));
|
|
72
|
+
vi.unstubAllEnvs();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('imports the app module lazily through the bootstrap accessor', async () => {
|
|
76
|
+
await import(entry);
|
|
77
|
+
const call = mocks.bootstrap.mock.calls.at(-1)?.[0] as {
|
|
78
|
+
importApp: () => Promise<unknown>;
|
|
79
|
+
};
|
|
80
|
+
const imported = await call.importApp();
|
|
81
|
+
expect(imported).toHaveProperty(handlerName);
|
|
82
|
+
expect(imported).toBe(await import(appModule));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('resolves the app once per cold start and forwards every argument', async () => {
|
|
86
|
+
appHandler.mockResolvedValue(result);
|
|
87
|
+
mocks.getApp.mockResolvedValue(await import(appModule));
|
|
88
|
+
const module = await import(entry);
|
|
89
|
+
const handler = module[handlerName as keyof typeof module] as (
|
|
90
|
+
...args: unknown[]
|
|
91
|
+
) => Promise<unknown>;
|
|
92
|
+
const event = {} as APIGatewayProxyEventV2;
|
|
93
|
+
const context = { getRemainingTimeInMillis: () => 50_000 } as Context;
|
|
94
|
+
await expect(handler(event, context)).resolves.toEqual(result);
|
|
95
|
+
await handler(event, context);
|
|
96
|
+
expect(mocks.getApp).toHaveBeenCalledTimes(2);
|
|
97
|
+
expect(appHandler).toHaveBeenCalledTimes(2);
|
|
98
|
+
expect(appHandler).toHaveBeenLastCalledWith(event, context);
|
|
99
|
+
});
|
|
100
|
+
});
|