@learncard/lca-api-service 1.4.0 → 1.5.1
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 +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +52 -0
- package/bootstrapLambda.test.ts +76 -0
- package/bundle.mjs +9 -1
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/app.d.ts +67 -3
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +101 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +79 -1
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/UserKey.d.ts +11 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/publish-metafile.json +1 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +18 -4
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +16 -0
- package/dist/services/escrow-enclave/notifications.d.ts +19 -4
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
- package/dist/services/escrow-enclave/types.d.ts +52 -8
- package/dist/types/notifications.d.ts +5 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -117
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +22 -13
- package/project.json +8 -1
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +30 -135
- package/seedMigrationLambda.ts +15 -46
- package/serverless-local.yml +25 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +174 -37
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +2 -0
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +45 -2
- package/src/config/environment.ts +90 -23
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +9 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +321 -5
- package/src/models/UserKey.ts +91 -6
- package/src/models/index.ts +1 -0
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/escrow.ts +281 -34
- package/src/routes/keys.ts +5 -0
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
- package/src/services/escrow-enclave/README.md +81 -10
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +145 -7
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +340 -6
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
- package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
- package/src/services/escrow-enclave/types.ts +63 -17
- package/test/auth-tickets.spec.ts +480 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +963 -24
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +375 -13
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority.spec.ts +50 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +20 -3
- package/serverless.escrow-env.cjs +0 -20
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import * as Sentry from '@sentry/serverless';
|
|
3
|
+
|
|
4
|
+
import type { EscrowHold, MongoUserKeyType, RecoveryMethod } from '@models';
|
|
5
|
+
import type { TemplateNotification } from '../delivery';
|
|
6
|
+
import {
|
|
7
|
+
notifyEscrowHoldEvent,
|
|
8
|
+
ESCROW_NOTIFICATION_TIMEOUT_MS,
|
|
9
|
+
type EscrowHoldNotifierDeps,
|
|
10
|
+
type EscrowHoldEventKind,
|
|
11
|
+
} from './notifications';
|
|
12
|
+
|
|
13
|
+
const baseUserKey = (overrides: Partial<MongoUserKeyType> = {}): MongoUserKeyType => {
|
|
14
|
+
const now = new Date();
|
|
15
|
+
return {
|
|
16
|
+
contactMethod: { type: 'email', value: 'owner@example.com' },
|
|
17
|
+
authProviders: [{ type: 'firebase', id: 'user-1' }],
|
|
18
|
+
primaryDid: 'did:key:owner',
|
|
19
|
+
linkedDids: [],
|
|
20
|
+
keyProvider: 'sss',
|
|
21
|
+
shareVersion: 1,
|
|
22
|
+
previousAuthShares: [],
|
|
23
|
+
securityLevel: 'basic',
|
|
24
|
+
recoveryMethods: [],
|
|
25
|
+
migratedFromWeb3Auth: false,
|
|
26
|
+
createdAt: now,
|
|
27
|
+
updatedAt: now,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const baseHold = (overrides: Partial<EscrowHold> = {}): EscrowHold => {
|
|
33
|
+
const now = new Date();
|
|
34
|
+
return {
|
|
35
|
+
holdRecord: {
|
|
36
|
+
hold: {
|
|
37
|
+
holdId: 'hold-1',
|
|
38
|
+
did: 'did:key:owner',
|
|
39
|
+
shareVersion: 1,
|
|
40
|
+
blobHash: 'ab'.repeat(32),
|
|
41
|
+
enrollmentEpoch: 1,
|
|
42
|
+
releasePolicy: 'hold',
|
|
43
|
+
clientEphemeralPublicKey: 'public-key',
|
|
44
|
+
createdLo: 0,
|
|
45
|
+
createdHi: 0,
|
|
46
|
+
policyVersion: 1,
|
|
47
|
+
signature: 'test-signature',
|
|
48
|
+
},
|
|
49
|
+
holdDurationMs: 0,
|
|
50
|
+
ledgerSeq: 0,
|
|
51
|
+
},
|
|
52
|
+
_id: 'hold-1',
|
|
53
|
+
authProvider: { type: 'firebase', id: 'user-1' },
|
|
54
|
+
primaryDid: 'did:key:owner',
|
|
55
|
+
shareVersion: 1,
|
|
56
|
+
status: 'pending',
|
|
57
|
+
identityProofType: 'auth-token',
|
|
58
|
+
requestedAt: now,
|
|
59
|
+
releaseAfter: now,
|
|
60
|
+
releasePolicy: 'hold',
|
|
61
|
+
clientEphemeralPublicKey: 'client-pub',
|
|
62
|
+
resumeTokenHash: 'a'.repeat(64),
|
|
63
|
+
notifications: [],
|
|
64
|
+
createdAt: now,
|
|
65
|
+
updatedAt: now,
|
|
66
|
+
...overrides,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const confirmedRecoveryMethod = (confirmationEmail: string): RecoveryMethod => ({
|
|
71
|
+
type: 'email',
|
|
72
|
+
createdAt: new Date(),
|
|
73
|
+
confirmationStatus: 'confirmed',
|
|
74
|
+
confirmedAt: new Date(),
|
|
75
|
+
confirmationEmail,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const pendingRecoveryMethod = (confirmationEmail: string): RecoveryMethod => ({
|
|
79
|
+
type: 'email',
|
|
80
|
+
createdAt: new Date(),
|
|
81
|
+
confirmationStatus: 'pending',
|
|
82
|
+
confirmationEmail,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const makeDeps = (overrides: Partial<EscrowHoldNotifierDeps> = {}) => {
|
|
86
|
+
const sent: TemplateNotification[] = [];
|
|
87
|
+
const pushCalls: unknown[] = [];
|
|
88
|
+
const recorded: { holdId: string; kind: EscrowHoldEventKind }[] = [];
|
|
89
|
+
const deps: EscrowHoldNotifierDeps = {
|
|
90
|
+
send: async notification => {
|
|
91
|
+
sent.push(notification as TemplateNotification);
|
|
92
|
+
},
|
|
93
|
+
sendPush: async (notification): ReturnType<EscrowHoldNotifierDeps['sendPush']> => {
|
|
94
|
+
pushCalls.push(notification);
|
|
95
|
+
return { successCount: 1, failureCount: 0, failedTokens: [] };
|
|
96
|
+
},
|
|
97
|
+
now: () => new Date('2026-01-01T00:00:00.000Z'),
|
|
98
|
+
rotateCancelToken: async () => 'b'.repeat(64),
|
|
99
|
+
recordNotification: async (holdId, kind) => {
|
|
100
|
+
recorded.push({ holdId, kind });
|
|
101
|
+
},
|
|
102
|
+
...overrides,
|
|
103
|
+
};
|
|
104
|
+
return { deps, sent, pushCalls, recorded };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
describe('notifyEscrowHoldEvent', () => {
|
|
108
|
+
it('emails every verified address deduped case-insensitively, excluding unconfirmed methods', async () => {
|
|
109
|
+
const userKey = baseUserKey({
|
|
110
|
+
contactMethod: { type: 'email', value: 'Owner@Example.com' },
|
|
111
|
+
recoveryEmail: 'owner@example.com',
|
|
112
|
+
recoveryEmailVerifiedAt: new Date(),
|
|
113
|
+
recoveryMethods: [
|
|
114
|
+
confirmedRecoveryMethod('secondary@example.com'),
|
|
115
|
+
pendingRecoveryMethod('attacker@example.com'),
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
const hold = baseHold({ completedAt: new Date() });
|
|
119
|
+
const { deps, sent } = makeDeps();
|
|
120
|
+
|
|
121
|
+
await notifyEscrowHoldEvent({ kind: 'completed', hold, userKey }, deps);
|
|
122
|
+
|
|
123
|
+
const recipients = sent.map(n => n.to).sort();
|
|
124
|
+
expect(recipients).toEqual(['owner@example.com', 'secondary@example.com']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('excludes a recoveryEmail that lacks recoveryEmailVerifiedAt', async () => {
|
|
128
|
+
const userKey = baseUserKey({
|
|
129
|
+
contactMethod: { type: 'phone', value: '+15555550100' },
|
|
130
|
+
recoveryEmail: 'unverified@example.com',
|
|
131
|
+
});
|
|
132
|
+
const hold = baseHold({ completedAt: new Date() });
|
|
133
|
+
const { deps, sent } = makeDeps();
|
|
134
|
+
|
|
135
|
+
await notifyEscrowHoldEvent({ kind: 'completed', hold, userKey }, deps);
|
|
136
|
+
|
|
137
|
+
expect(sent).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('sends the started email with a cancel link built from the plaintext cancelToken, never rotating', async () => {
|
|
141
|
+
const userKey = baseUserKey();
|
|
142
|
+
const hold = baseHold();
|
|
143
|
+
const rotateCancelToken = vi.fn(async () => {
|
|
144
|
+
throw new Error('must not rotate for started');
|
|
145
|
+
});
|
|
146
|
+
const { deps, sent } = makeDeps({ rotateCancelToken });
|
|
147
|
+
const cancelToken = 'a'.repeat(64);
|
|
148
|
+
|
|
149
|
+
await notifyEscrowHoldEvent({ kind: 'started', hold, userKey, cancelToken }, deps);
|
|
150
|
+
|
|
151
|
+
expect(rotateCancelToken).not.toHaveBeenCalled();
|
|
152
|
+
expect(sent).toHaveLength(1);
|
|
153
|
+
expect(sent[0]!.templateAlias).toBe('escrow-hold-started');
|
|
154
|
+
expect(sent[0]!.templateModel.cancelUrl).toContain(cancelToken);
|
|
155
|
+
expect(sent[0]!.templateModel.cancelUrl).toContain(hold._id);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('rotates a fresh cancel token for reminder emails', async () => {
|
|
159
|
+
const userKey = baseUserKey();
|
|
160
|
+
const hold = baseHold();
|
|
161
|
+
const rotateCancelToken = vi.fn(async () => 'c'.repeat(64));
|
|
162
|
+
const { deps, sent } = makeDeps({ rotateCancelToken });
|
|
163
|
+
|
|
164
|
+
await notifyEscrowHoldEvent({ kind: 'reminder', hold, userKey }, deps);
|
|
165
|
+
|
|
166
|
+
expect(rotateCancelToken).toHaveBeenCalledWith(hold._id);
|
|
167
|
+
expect(sent[0]!.templateAlias).toBe('escrow-hold-reminder');
|
|
168
|
+
expect(sent[0]!.templateModel.cancelUrl).toContain('c'.repeat(64));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('pin-locked with an active waiting-period hold rotates that hold (not the PIN hold) and links to it', async () => {
|
|
172
|
+
const userKey = baseUserKey();
|
|
173
|
+
// The PIN hold itself is already terminal by the time 'pin-locked' fires.
|
|
174
|
+
const hold = baseHold({
|
|
175
|
+
_id: 'pin-hold',
|
|
176
|
+
releasePolicy: 'pin',
|
|
177
|
+
status: 'cancelled',
|
|
178
|
+
cancelledBy: 'system',
|
|
179
|
+
cancelReason: 'pin-locked',
|
|
180
|
+
});
|
|
181
|
+
const activeHold = baseHold({
|
|
182
|
+
_id: 'active-hold',
|
|
183
|
+
releasePolicy: 'hold',
|
|
184
|
+
status: 'pending',
|
|
185
|
+
});
|
|
186
|
+
const rotateCancelToken = vi.fn(async () => 'd'.repeat(64));
|
|
187
|
+
const { deps, sent } = makeDeps({ rotateCancelToken });
|
|
188
|
+
|
|
189
|
+
await notifyEscrowHoldEvent({ kind: 'pin-locked', hold, userKey, activeHold }, deps);
|
|
190
|
+
|
|
191
|
+
expect(rotateCancelToken).toHaveBeenCalledExactlyOnceWith(activeHold._id);
|
|
192
|
+
expect(sent[0]!.templateAlias).toBe('escrow-pin-locked');
|
|
193
|
+
expect(sent[0]!.templateModel.cancelUrl).toContain('d'.repeat(64));
|
|
194
|
+
expect(sent[0]!.templateModel.cancelUrl).toContain(activeHold._id);
|
|
195
|
+
expect(sent[0]!.templateModel.releaseAfter).toBe(activeHold.releaseAfter.toISOString());
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('pin-locked without an active hold sends no cancelUrl/releaseAfter and never rotates the PIN hold', async () => {
|
|
199
|
+
const userKey = baseUserKey();
|
|
200
|
+
const hold = baseHold({
|
|
201
|
+
_id: 'pin-hold',
|
|
202
|
+
releasePolicy: 'pin',
|
|
203
|
+
status: 'cancelled',
|
|
204
|
+
cancelledBy: 'system',
|
|
205
|
+
cancelReason: 'pin-locked',
|
|
206
|
+
});
|
|
207
|
+
const rotateCancelToken = vi.fn(async () => 'e'.repeat(64));
|
|
208
|
+
const { deps, sent } = makeDeps({ rotateCancelToken });
|
|
209
|
+
|
|
210
|
+
await notifyEscrowHoldEvent({ kind: 'pin-locked', hold, userKey }, deps);
|
|
211
|
+
|
|
212
|
+
expect(rotateCancelToken).not.toHaveBeenCalled();
|
|
213
|
+
expect(sent[0]!.templateAlias).toBe('escrow-pin-locked');
|
|
214
|
+
expect(sent[0]!.templateModel).not.toHaveProperty('cancelUrl');
|
|
215
|
+
expect(sent[0]!.templateModel).not.toHaveProperty('releaseAfter');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('pin-locked treats a non-pending activeHold the same as no active hold', async () => {
|
|
219
|
+
const userKey = baseUserKey();
|
|
220
|
+
const hold = baseHold({ _id: 'pin-hold', releasePolicy: 'pin', status: 'cancelled' });
|
|
221
|
+
const activeHold = baseHold({ _id: 'stale-hold', status: 'completed' });
|
|
222
|
+
const rotateCancelToken = vi.fn(async () => 'f'.repeat(64));
|
|
223
|
+
const { deps, sent } = makeDeps({ rotateCancelToken });
|
|
224
|
+
|
|
225
|
+
await notifyEscrowHoldEvent({ kind: 'pin-locked', hold, userKey, activeHold }, deps);
|
|
226
|
+
|
|
227
|
+
expect(rotateCancelToken).not.toHaveBeenCalled();
|
|
228
|
+
expect(sent[0]!.templateModel).not.toHaveProperty('cancelUrl');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('skips the reminder email entirely when a fresh token cannot be issued', async () => {
|
|
232
|
+
const userKey = baseUserKey();
|
|
233
|
+
const hold = baseHold();
|
|
234
|
+
const { deps, sent } = makeDeps({ rotateCancelToken: async () => null });
|
|
235
|
+
|
|
236
|
+
await notifyEscrowHoldEvent({ kind: 'reminder', hold, userKey }, deps);
|
|
237
|
+
|
|
238
|
+
expect(sent).toHaveLength(0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('sends a push notification to the account DID for every kind', async () => {
|
|
242
|
+
const userKey = baseUserKey({ contactMethod: { type: 'phone', value: '+15555550100' } });
|
|
243
|
+
const hold = baseHold();
|
|
244
|
+
const { deps, sent, pushCalls } = makeDeps();
|
|
245
|
+
|
|
246
|
+
await notifyEscrowHoldEvent(
|
|
247
|
+
{ kind: 'started', hold, userKey, cancelToken: 'a'.repeat(64) },
|
|
248
|
+
deps
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(sent).toHaveLength(0);
|
|
252
|
+
expect(pushCalls).toEqual([
|
|
253
|
+
expect.objectContaining({ type: 'APP_NOTIFICATION', to: { did: userKey.primaryDid } }),
|
|
254
|
+
]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('keeps the security email pending even when push succeeds and logs only safe metadata', async () => {
|
|
258
|
+
const userKey = baseUserKey();
|
|
259
|
+
const hold = baseHold();
|
|
260
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
261
|
+
const sentrySpy = vi.spyOn(Sentry, 'captureException').mockImplementation(() => 'event-id');
|
|
262
|
+
const { deps, recorded } = makeDeps({
|
|
263
|
+
send: async () => {
|
|
264
|
+
throw new Error('Postmark rejected owner@example.com');
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await notifyEscrowHoldEvent(
|
|
269
|
+
{ kind: 'started', hold, userKey, cancelToken: 'a'.repeat(64) },
|
|
270
|
+
deps
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(recorded).toEqual([]);
|
|
274
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
275
|
+
expect(sentrySpy).toHaveBeenCalled();
|
|
276
|
+
|
|
277
|
+
const loggedText = JSON.stringify([...errorSpy.mock.calls, ...sentrySpy.mock.calls]);
|
|
278
|
+
expect(loggedText).not.toContain('owner@example.com');
|
|
279
|
+
expect(loggedText).not.toContain(userKey.primaryDid);
|
|
280
|
+
expect(loggedText).not.toContain('a'.repeat(64));
|
|
281
|
+
|
|
282
|
+
errorSpy.mockRestore();
|
|
283
|
+
sentrySpy.mockRestore();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('never throws and records nothing when every channel fails', async () => {
|
|
287
|
+
const userKey = baseUserKey();
|
|
288
|
+
const hold = baseHold();
|
|
289
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
290
|
+
vi.spyOn(Sentry, 'captureException').mockImplementation(() => 'event-id');
|
|
291
|
+
const { deps, recorded } = makeDeps({
|
|
292
|
+
send: async () => {
|
|
293
|
+
throw new Error('email down');
|
|
294
|
+
},
|
|
295
|
+
sendPush: async () => {
|
|
296
|
+
throw new Error('push down');
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await expect(
|
|
301
|
+
notifyEscrowHoldEvent(
|
|
302
|
+
{ kind: 'started', hold, userKey, cancelToken: 'a'.repeat(64) },
|
|
303
|
+
deps
|
|
304
|
+
)
|
|
305
|
+
).resolves.toBe(false);
|
|
306
|
+
expect(recorded).toHaveLength(0);
|
|
307
|
+
|
|
308
|
+
vi.restoreAllMocks();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('contains template token and persistence rejections', async () => {
|
|
312
|
+
for (const overrides of [
|
|
313
|
+
{
|
|
314
|
+
rotateCancelToken: async () => {
|
|
315
|
+
throw new Error('token DB failed');
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
recordNotification: async () => {
|
|
320
|
+
throw new Error('record DB failed');
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
]) {
|
|
324
|
+
const { deps } = makeDeps(overrides);
|
|
325
|
+
await expect(
|
|
326
|
+
notifyEscrowHoldEvent(
|
|
327
|
+
{ kind: 'reminder', hold: baseHold(), userKey: baseUserKey() },
|
|
328
|
+
deps
|
|
329
|
+
)
|
|
330
|
+
).resolves.toBe(false);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it.each(['started', 'reminder'] as const)(
|
|
335
|
+
'retries %s when only one verified inbox receives its email',
|
|
336
|
+
async kind => {
|
|
337
|
+
const { deps, recorded } = makeDeps({
|
|
338
|
+
send: async notification => {
|
|
339
|
+
if (notification.to === 'recovery@example.com')
|
|
340
|
+
throw new Error('delivery failed');
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
const userKey = baseUserKey({
|
|
344
|
+
recoveryEmail: 'recovery@example.com',
|
|
345
|
+
recoveryEmailVerifiedAt: new Date(),
|
|
346
|
+
});
|
|
347
|
+
expect(await notifyEscrowHoldEvent({ kind, hold: baseHold(), userKey }, deps)).toBe(
|
|
348
|
+
false
|
|
349
|
+
);
|
|
350
|
+
expect(recorded).toEqual([]);
|
|
351
|
+
}
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
it('bounds hanging delivery and consumes its late rejection', async () => {
|
|
355
|
+
vi.useFakeTimers();
|
|
356
|
+
try {
|
|
357
|
+
let rejectSend!: (error: Error) => void;
|
|
358
|
+
const { deps, recorded } = makeDeps({
|
|
359
|
+
send: () =>
|
|
360
|
+
new Promise((_, reject) => {
|
|
361
|
+
rejectSend = reject;
|
|
362
|
+
}),
|
|
363
|
+
});
|
|
364
|
+
const pending = notifyEscrowHoldEvent(
|
|
365
|
+
{ kind: 'reminder', hold: baseHold(), userKey: baseUserKey() },
|
|
366
|
+
deps
|
|
367
|
+
);
|
|
368
|
+
await vi.advanceTimersByTimeAsync(ESCROW_NOTIFICATION_TIMEOUT_MS);
|
|
369
|
+
expect(await pending).toBe(false);
|
|
370
|
+
rejectSend(new Error('late failure'));
|
|
371
|
+
await vi.runAllTimersAsync();
|
|
372
|
+
expect(recorded).toEqual([]);
|
|
373
|
+
} finally {
|
|
374
|
+
vi.useRealTimers();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
});
|