@learncard/lca-api-service 1.5.1 → 1.5.3
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 +2 -2
- package/CHANGELOG.md +32 -0
- package/config/config.dev.json +1 -1
- package/dist/app.d.ts +239 -1
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +30 -2
- package/dist/models/UserKey.d.ts +89 -5
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/ai.test.d.ts +1 -0
- package/dist/routes/escrow.d.ts +237 -0
- package/dist/routes/index.d.ts +4 -4
- package/dist/routes/keys.d.ts +2 -1
- package/dist/services/escrow-enclave/authorityContract.d.ts +170 -0
- package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
- package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +55 -3
- package/esbuildPlugins.cjs +9 -6
- package/package.json +10 -10
- package/src/config/environment.ts +1 -1
- package/src/config/escrowDeployment.test.ts +8 -0
- package/src/jobs/escrowBlobRewrap.ts +45 -2
- package/src/jobs/escrowHoldReminders.ts +50 -1
- package/src/models/EscrowHold.ts +128 -10
- package/src/models/UserKey.ts +59 -8
- package/src/routes/ai.test.ts +103 -0
- package/src/routes/ai.ts +2 -1
- package/src/routes/escrow.ts +386 -53
- package/src/routes/index.ts +1 -1
- package/src/routes/keys.ts +5 -0
- package/src/services/escrow-enclave/README.md +31 -0
- package/src/services/escrow-enclave/authorityContract.ts +126 -0
- package/src/services/escrow-enclave/cancellation.ts +2 -2
- package/src/services/escrow-enclave/logFailure.test.ts +64 -0
- package/src/services/escrow-enclave/logFailure.ts +29 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +315 -4
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +182 -2
- package/src/services/escrow-enclave/remoteEnclave.ts +202 -31
- package/src/services/escrow-enclave/types.ts +77 -5
- package/test/jobs/escrowHoldReminders.spec.ts +195 -3
- package/test/keys-escrow.spec.ts +587 -11
- package/test/models/escrow.spec.ts +13 -0
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import type { EscrowEnvelope } from '@learncard/sss-key-manager';
|
|
1
|
+
import type { EscrowEnvelope, SignedEscrowEnrollmentIntent } from '@learncard/sss-key-manager';
|
|
2
|
+
import type {
|
|
3
|
+
AuthorityRecord,
|
|
4
|
+
AuthorityHoldReceipt,
|
|
5
|
+
AuthorityMutationResult,
|
|
6
|
+
AuthorityStateResponse,
|
|
7
|
+
} from './authorityContract';
|
|
8
|
+
export interface ApplyEnrollmentInput {
|
|
9
|
+
signedIntent: SignedEscrowEnrollmentIntent;
|
|
10
|
+
sourceEnvelope: EscrowEnvelope | null;
|
|
11
|
+
targetEnvelope: EscrowEnvelope | null;
|
|
12
|
+
}
|
|
2
13
|
// Hold records are independent of host Mongo status and deadlines.
|
|
3
14
|
|
|
4
15
|
export interface EnclaveAttestation {
|
|
@@ -41,11 +52,25 @@ export interface EnclaveCreateHoldInput {
|
|
|
41
52
|
clientEphemeralPublicKey: string;
|
|
42
53
|
}
|
|
43
54
|
export interface CancelHoldRequest {
|
|
55
|
+
requestId?: string;
|
|
44
56
|
envelope: EscrowEnvelope;
|
|
45
57
|
hold: EscrowHoldRecord;
|
|
46
58
|
clientEphemeralPublicKey: string;
|
|
47
59
|
expectedDid: string;
|
|
48
60
|
}
|
|
61
|
+
/** P9.1 (attack C): `code` is the one-time code itself (compared against a
|
|
62
|
+
* stored hash enclave-side), never a proof — unlike `ReleaseRequest.pinProof`. */
|
|
63
|
+
export interface ConfirmHoldRequest extends CancelHoldRequest {
|
|
64
|
+
code: string;
|
|
65
|
+
}
|
|
66
|
+
export type ResendCodeRequest = CancelHoldRequest;
|
|
67
|
+
/** P9.1 (attack C): system-initiated (lca-api's hourly job) — no envelope or
|
|
68
|
+
* signed hold, since there is no user action to authenticate. */
|
|
69
|
+
export interface SendReleaseWarningRequest {
|
|
70
|
+
requestId?: string;
|
|
71
|
+
holdId: string;
|
|
72
|
+
expectedDid: string;
|
|
73
|
+
}
|
|
49
74
|
export interface VerifyEscrowBlobInput {
|
|
50
75
|
envelope: EscrowEnvelope;
|
|
51
76
|
expectedDid: string;
|
|
@@ -60,6 +85,7 @@ export interface ReleaseRequest extends CancelHoldRequest {
|
|
|
60
85
|
}
|
|
61
86
|
export interface ReleaseResult {
|
|
62
87
|
sealed: EscrowEnvelope;
|
|
88
|
+
receipt?: AuthorityRecord;
|
|
63
89
|
}
|
|
64
90
|
export interface CarryPinVerifierInput {
|
|
65
91
|
sourceEnvelope: EscrowEnvelope;
|
|
@@ -80,6 +106,12 @@ export interface RewrapEscrowBlobInput {
|
|
|
80
106
|
sourceEnrollmentEpoch: number;
|
|
81
107
|
}
|
|
82
108
|
export interface EscrowEnclave {
|
|
109
|
+
/** Remote authority only; software mode must not impersonate signed authority. */
|
|
110
|
+
getEnrollmentState?(input: {
|
|
111
|
+
expectedDid: string;
|
|
112
|
+
nonce: string;
|
|
113
|
+
}): Promise<AuthorityStateResponse>;
|
|
114
|
+
applyEnrollmentIntent?(input: ApplyEnrollmentInput): Promise<AuthorityMutationResult>;
|
|
83
115
|
/** `nonce` binds a nitro attestation to one client-generated challenge (64 hex chars,
|
|
84
116
|
* decoded to bytes); the software backend has no freshness story and ignores it. */
|
|
85
117
|
getAttestation(nonce?: Uint8Array): Promise<EnclaveAttestation>;
|
|
@@ -91,10 +123,34 @@ export interface EscrowEnclave {
|
|
|
91
123
|
* `EscrowBlobError`) unless the envelope's own keyId is a recognised
|
|
92
124
|
* previous key — never the current key (nothing to migrate) or an
|
|
93
125
|
* unknown one. */
|
|
94
|
-
rewrapEscrowBlob(
|
|
126
|
+
rewrapEscrowBlob(
|
|
127
|
+
input: RewrapEscrowBlobInput
|
|
128
|
+
): Promise<{ envelope: EscrowEnvelope; authority?: AuthorityMutationResult }>;
|
|
95
129
|
releaseEscrow(input: ReleaseRequest): Promise<ReleaseResult>;
|
|
96
|
-
createHold(input: EnclaveCreateHoldInput): Promise<{
|
|
97
|
-
|
|
130
|
+
createHold(input: EnclaveCreateHoldInput): Promise<{
|
|
131
|
+
holdRecord: EscrowHoldRecord;
|
|
132
|
+
delivery?: { codeSent: boolean; proof: AuthorityHoldReceipt };
|
|
133
|
+
}>;
|
|
134
|
+
getHoldState?(input: {
|
|
135
|
+
expectedDid: string;
|
|
136
|
+
hold: EscrowHoldRecord;
|
|
137
|
+
nonce: string;
|
|
138
|
+
}): Promise<{ proof: AuthorityRecord }>;
|
|
139
|
+
cancelHold(input: CancelHoldRequest): Promise<void | { receipt: AuthorityRecord }>;
|
|
140
|
+
/** P9.1 (attack C): authority (nitro) mode only — software mode never
|
|
141
|
+
* produces an awaiting-confirmation hold, so it has nothing to confirm.
|
|
142
|
+
* `confirmedAtMs` is the enclave's authoritative confirmation time
|
|
143
|
+
* (present on both a fresh confirm and an idempotent retry); callers
|
|
144
|
+
* must use it instead of a locally-observed clock to compute deadlines. */
|
|
145
|
+
confirmHold?(
|
|
146
|
+
input: ConfirmHoldRequest
|
|
147
|
+
): Promise<{ receipt: AuthorityHoldReceipt; confirmedAtMs: number }>;
|
|
148
|
+
resendCode?(input: ResendCodeRequest): Promise<{ receipt: AuthorityRecord }>;
|
|
149
|
+
/** `releaseWarningSentAtMs` is the enclave's authoritative warning-sent
|
|
150
|
+
* time, present on both a fresh send and an idempotent retry. */
|
|
151
|
+
sendReleaseWarning?(
|
|
152
|
+
input: SendReleaseWarningRequest
|
|
153
|
+
): Promise<{ receipt: AuthorityHoldReceipt; releaseWarningSentAtMs: number }>;
|
|
98
154
|
}
|
|
99
155
|
export class EscrowPolicyError extends Error {
|
|
100
156
|
constructor() {
|
|
@@ -102,6 +158,12 @@ export class EscrowPolicyError extends Error {
|
|
|
102
158
|
this.name = 'EscrowPolicyError';
|
|
103
159
|
}
|
|
104
160
|
}
|
|
161
|
+
export class EscrowCodeError extends Error {
|
|
162
|
+
constructor(public readonly reason: 'codeMismatch' | 'codeExpired' | 'codeLocked') {
|
|
163
|
+
super(reason);
|
|
164
|
+
this.name = 'EscrowCodeError';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
105
167
|
export class EscrowPinMismatchError extends Error {
|
|
106
168
|
constructor() {
|
|
107
169
|
super('Incorrect PIN.');
|
|
@@ -115,7 +177,17 @@ export class EscrowBlobError extends Error {
|
|
|
115
177
|
}
|
|
116
178
|
}
|
|
117
179
|
export class EscrowUnavailableError extends Error {
|
|
118
|
-
constructor(
|
|
180
|
+
constructor(
|
|
181
|
+
readonly reason:
|
|
182
|
+
| 'unavailable'
|
|
183
|
+
| 'ledger'
|
|
184
|
+
| 'time'
|
|
185
|
+
| 'transport'
|
|
186
|
+
| 'invalid_response'
|
|
187
|
+
| 'invalid_hold'
|
|
188
|
+
| 'http_refusal'
|
|
189
|
+
| 'rate_limit' = 'unavailable'
|
|
190
|
+
) {
|
|
119
191
|
super('Escrow recovery is not available.');
|
|
120
192
|
this.name = 'EscrowUnavailableError';
|
|
121
193
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
|
-
import { beforeAll, afterAll, afterEach, describe, it, expect } from 'vitest';
|
|
2
|
+
import { beforeAll, afterAll, afterEach, beforeEach, describe, it, expect } from 'vitest';
|
|
3
3
|
import { client } from '@mongo';
|
|
4
4
|
import {
|
|
5
5
|
createUserKeysIndexes,
|
|
@@ -14,10 +14,14 @@ import {
|
|
|
14
14
|
generateEscrowResumeToken,
|
|
15
15
|
hashEscrowResumeToken,
|
|
16
16
|
recordEscrowHoldNotification,
|
|
17
|
+
computeEffectiveReleaseAfter,
|
|
18
|
+
ESCROW_RELEASE_TIME_MARGIN_MS,
|
|
19
|
+
ESCROW_RELEASE_WARNING_GAP_MS,
|
|
17
20
|
type AuthProviderMapping,
|
|
18
21
|
} from '@models';
|
|
19
22
|
import {
|
|
20
23
|
__setEscrowEnclaveForTests,
|
|
24
|
+
getEscrowEnclave,
|
|
21
25
|
notifyEscrowHoldEvent,
|
|
22
26
|
} from '../../src/services/escrow-enclave';
|
|
23
27
|
import { escrowCancelTokenMatches, hashEscrowCancelToken } from '@helpers/escrowCancelToken';
|
|
@@ -91,6 +95,12 @@ beforeAll(async () => {
|
|
|
91
95
|
await createEscrowHoldsIndexes();
|
|
92
96
|
});
|
|
93
97
|
|
|
98
|
+
// The reminder job scans every due hold (including overdue catch-up), so holds
|
|
99
|
+
// left behind by other spec files sharing this Mongo would be picked up.
|
|
100
|
+
beforeEach(async () => {
|
|
101
|
+
await getEscrowHoldsCollection().deleteMany({});
|
|
102
|
+
});
|
|
103
|
+
|
|
94
104
|
afterEach(async () => {
|
|
95
105
|
const ids = seededProviderIds.splice(0);
|
|
96
106
|
if (ids.length === 0) return;
|
|
@@ -114,10 +124,11 @@ describe('runEscrowHoldReminders', () => {
|
|
|
114
124
|
);
|
|
115
125
|
for (const hold of holds) await cancelEscrowHold(hold._id, 'did');
|
|
116
126
|
// These legacy test records lack ciphertext: fail closed, but still rotate
|
|
117
|
-
// the bounded queue rather than retrying the same first
|
|
127
|
+
// the bounded queue rather than retrying the same first ones forever. The
|
|
128
|
+
// default 15s enclave timeout fits two calls in the job's 30s enclave budget.
|
|
118
129
|
await runEscrowHoldReminders({ now, limit: 200 });
|
|
119
130
|
const firstRun = await Promise.all(holds.map(hold => findEscrowHoldById(hold._id)));
|
|
120
|
-
expect(firstRun.map(hold => hold?.enclaveCancelAttempts).sort()).toEqual([0,
|
|
131
|
+
expect(firstRun.map(hold => hold?.enclaveCancelAttempts).sort()).toEqual([0, 0, 1, 1]);
|
|
121
132
|
const unattempted = firstRun.find(hold => hold?.enclaveCancelAttempts === 0)!;
|
|
122
133
|
await runEscrowHoldReminders({ now, limit: 1 });
|
|
123
134
|
expect((await findEscrowHoldById(unattempted._id))?.enclaveCancelAttempts).toBe(1);
|
|
@@ -153,6 +164,187 @@ describe('runEscrowHoldReminders', () => {
|
|
|
153
164
|
expect(attempts[1]).toBe(reminder._id);
|
|
154
165
|
});
|
|
155
166
|
|
|
167
|
+
it('calls the enclave sendReleaseWarning instead of its own reminder notification for an authority-mode hold', async () => {
|
|
168
|
+
const now = new Date();
|
|
169
|
+
const hold = await seed(new Date(now.getTime() + HOUR));
|
|
170
|
+
await getEscrowHoldsCollection().updateOne(
|
|
171
|
+
{ _id: hold._id },
|
|
172
|
+
{ $set: { requiresConfirmation: true, confirmedAt: now } }
|
|
173
|
+
);
|
|
174
|
+
const calls: { holdId: string; expectedDid: string }[] = [];
|
|
175
|
+
// This file has no per-test enclave reset (unlike keys-escrow.spec.ts's
|
|
176
|
+
// beforeEach): Object.assign mutates the shared software singleton in
|
|
177
|
+
// place, so it must be cleaned up or it leaks into every later test.
|
|
178
|
+
const software = getEscrowEnclave() as unknown as Record<string, unknown>;
|
|
179
|
+
software.sendReleaseWarning = async (params: { holdId: string; expectedDid: string }) => {
|
|
180
|
+
calls.push(params);
|
|
181
|
+
return { success: true as const };
|
|
182
|
+
};
|
|
183
|
+
try {
|
|
184
|
+
const notified: string[] = [];
|
|
185
|
+
const notify: typeof notifyEscrowHoldEvent = async event => {
|
|
186
|
+
notified.push(event.hold._id);
|
|
187
|
+
return true;
|
|
188
|
+
};
|
|
189
|
+
const result = await runEscrowHoldReminders({ now, notify });
|
|
190
|
+
expect(result.reminded).toBe(1);
|
|
191
|
+
expect(calls).toEqual([
|
|
192
|
+
{ holdId: hold.holdRecord.hold.holdId, expectedDid: hold.primaryDid },
|
|
193
|
+
]);
|
|
194
|
+
expect(notified).toHaveLength(0);
|
|
195
|
+
const stored = await findEscrowHoldById(hold._id);
|
|
196
|
+
// P2-a: a successful enclave warning is now acknowledged in Mongo
|
|
197
|
+
// (previously left empty forever, causing a re-send every run).
|
|
198
|
+
expect(stored?.notifications.map(entry => entry.kind)).toEqual(['reminder']);
|
|
199
|
+
} finally {
|
|
200
|
+
delete software.sendReleaseWarning;
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('sends the release warning exactly once across consecutive hourly runs (P2-a)', async () => {
|
|
205
|
+
const now = new Date();
|
|
206
|
+
const hold = await seed(new Date(now.getTime() + HOUR));
|
|
207
|
+
await getEscrowHoldsCollection().updateOne(
|
|
208
|
+
{ _id: hold._id },
|
|
209
|
+
{ $set: { requiresConfirmation: true, confirmedAt: now } }
|
|
210
|
+
);
|
|
211
|
+
const calls: { holdId: string; expectedDid: string }[] = [];
|
|
212
|
+
const software = getEscrowEnclave() as unknown as Record<string, unknown>;
|
|
213
|
+
software.sendReleaseWarning = async (params: { holdId: string; expectedDid: string }) => {
|
|
214
|
+
calls.push(params);
|
|
215
|
+
return { success: true as const };
|
|
216
|
+
};
|
|
217
|
+
try {
|
|
218
|
+
const first = await runEscrowHoldReminders({ now });
|
|
219
|
+
const secondRun = await runEscrowHoldReminders({ now: new Date(now.getTime() + HOUR) });
|
|
220
|
+
expect(first.reminded).toBe(1);
|
|
221
|
+
expect(secondRun.reminded).toBe(0);
|
|
222
|
+
expect(calls).toHaveLength(1);
|
|
223
|
+
expect(
|
|
224
|
+
(await findEscrowHoldById(hold._id))?.notifications.map(entry => entry.kind)
|
|
225
|
+
).toEqual(['reminder']);
|
|
226
|
+
} finally {
|
|
227
|
+
delete software.sendReleaseWarning;
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('a lost acknowledgment write is safe to retry and eventually stops re-calling the enclave (P2-a)', async () => {
|
|
232
|
+
const now = new Date();
|
|
233
|
+
const hold = await seed(new Date(now.getTime() + HOUR));
|
|
234
|
+
await getEscrowHoldsCollection().updateOne(
|
|
235
|
+
{ _id: hold._id },
|
|
236
|
+
{ $set: { requiresConfirmation: true, confirmedAt: now } }
|
|
237
|
+
);
|
|
238
|
+
const calls: { holdId: string; expectedDid: string }[] = [];
|
|
239
|
+
const software = getEscrowEnclave() as unknown as Record<string, unknown>;
|
|
240
|
+
software.sendReleaseWarning = async (params: { holdId: string; expectedDid: string }) => {
|
|
241
|
+
calls.push(params);
|
|
242
|
+
return {
|
|
243
|
+
receipt: {
|
|
244
|
+
version: 1 as const,
|
|
245
|
+
kind: calls.length === 1 ? ('commit' as const) : ('snapshot' as const),
|
|
246
|
+
keyId: 'test-key',
|
|
247
|
+
measurement: Array.from({ length: 32 }, () => 0),
|
|
248
|
+
payload:
|
|
249
|
+
calls.length === 1
|
|
250
|
+
? 'AQ=='
|
|
251
|
+
: Buffer.from(
|
|
252
|
+
JSON.stringify([
|
|
253
|
+
'learncard-authority-hold-snapshot-v2',
|
|
254
|
+
params.holdId,
|
|
255
|
+
Array.from({ length: 32 }, () => 7),
|
|
256
|
+
'pending',
|
|
257
|
+
now.getTime(),
|
|
258
|
+
now.getTime(),
|
|
259
|
+
'g1',
|
|
260
|
+
])
|
|
261
|
+
).toString('base64'),
|
|
262
|
+
signature: Buffer.alloc(64).toString('base64'),
|
|
263
|
+
},
|
|
264
|
+
releaseWarningSentAtMs: now.getTime(),
|
|
265
|
+
};
|
|
266
|
+
};
|
|
267
|
+
try {
|
|
268
|
+
const first = await runEscrowHoldReminders({ now });
|
|
269
|
+
expect(first.reminded).toBe(1);
|
|
270
|
+
expect(calls).toHaveLength(1);
|
|
271
|
+
// Simulate the acknowledgment write being lost (e.g. a crash right
|
|
272
|
+
// after a successful enclave call) rather than mocking Mongo, so
|
|
273
|
+
// this exercises the job's own catch-up path: a real enclave would
|
|
274
|
+
// not re-send or move its timestamp on this retry (covered by the
|
|
275
|
+
// Rust-side idempotence test), but lca-api must not get stuck
|
|
276
|
+
// either, so it retries until its own acknowledgment lands.
|
|
277
|
+
await getEscrowHoldsCollection().updateOne(
|
|
278
|
+
{ _id: hold._id },
|
|
279
|
+
{ $set: { notifications: [], releaseAfter: hold.releaseAfter } }
|
|
280
|
+
);
|
|
281
|
+
const second = await runEscrowHoldReminders({ now });
|
|
282
|
+
expect(second.reminded).toBe(1);
|
|
283
|
+
expect(calls).toHaveLength(2);
|
|
284
|
+
expect(
|
|
285
|
+
(await findEscrowHoldById(hold._id))?.notifications.map(entry => entry.kind)
|
|
286
|
+
).toEqual(['reminder']);
|
|
287
|
+
const third = await runEscrowHoldReminders({ now });
|
|
288
|
+
expect(third.reminded).toBe(0);
|
|
289
|
+
expect(calls).toHaveLength(2);
|
|
290
|
+
} finally {
|
|
291
|
+
delete software.sendReleaseWarning;
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// [B] A catch-up warning sent well after the 7-day window elapsed (an
|
|
296
|
+
// outage, or the job simply not running for a while) must advertise an
|
|
297
|
+
// availability time derived from the LATER gate — the enclave's own
|
|
298
|
+
// `release_warning_sent_hi_ms + 24h` — not the hold's original,
|
|
299
|
+
// long-since-passed creation/confirmation-based estimate, which would
|
|
300
|
+
// otherwise keep telling the client it's ready when the enclave would
|
|
301
|
+
// still refuse release.
|
|
302
|
+
it('[B] an overdue warning advances the stored releaseAfter to the warning-based availability time', async () => {
|
|
303
|
+
const now = new Date();
|
|
304
|
+
// Confirmed 10 days ago: its own 7-day-plus-margin gate has long
|
|
305
|
+
// since passed, so only the warning-based gate can be the later one.
|
|
306
|
+
const confirmedAt = new Date(now.getTime() - 10 * 24 * HOUR);
|
|
307
|
+
const hold = await seed(new Date(confirmedAt.getTime() + HOUR));
|
|
308
|
+
await getEscrowHoldsCollection().updateOne(
|
|
309
|
+
{ _id: hold._id },
|
|
310
|
+
{ $set: { requiresConfirmation: true, confirmedAt } }
|
|
311
|
+
);
|
|
312
|
+
const releaseWarningSentAtMs = now.getTime();
|
|
313
|
+
const software = getEscrowEnclave() as unknown as Record<string, unknown>;
|
|
314
|
+
software.sendReleaseWarning = async () => ({
|
|
315
|
+
receipt: {
|
|
316
|
+
version: 1 as const,
|
|
317
|
+
kind: 'commit' as const,
|
|
318
|
+
keyId: 'test-key',
|
|
319
|
+
measurement: Array.from({ length: 32 }, () => 0),
|
|
320
|
+
payload: 'AQ==',
|
|
321
|
+
signature: Buffer.alloc(64).toString('base64'),
|
|
322
|
+
},
|
|
323
|
+
releaseWarningSentAtMs,
|
|
324
|
+
});
|
|
325
|
+
try {
|
|
326
|
+
const result = await runEscrowHoldReminders({ now });
|
|
327
|
+
expect(result.reminded).toBe(1);
|
|
328
|
+
const stored = await findEscrowHoldById(hold._id);
|
|
329
|
+
const expected = computeEffectiveReleaseAfter({
|
|
330
|
+
confirmedAtMs: confirmedAt.getTime(),
|
|
331
|
+
holdDurationMs: hold.holdRecord.holdDurationMs,
|
|
332
|
+
releaseWarningSentAtMs,
|
|
333
|
+
});
|
|
334
|
+
// Sanity: the warning-based gate really is the later one here.
|
|
335
|
+
expect(expected.getTime()).toBe(
|
|
336
|
+
releaseWarningSentAtMs +
|
|
337
|
+
ESCROW_RELEASE_WARNING_GAP_MS +
|
|
338
|
+
ESCROW_RELEASE_TIME_MARGIN_MS
|
|
339
|
+
);
|
|
340
|
+
expect(stored?.releaseAfter.getTime()).toBe(expected.getTime());
|
|
341
|
+
// And specifically later than the original (long-passed) estimate.
|
|
342
|
+
expect(stored!.releaseAfter.getTime()).toBeGreaterThan(hold.releaseAfter.getTime());
|
|
343
|
+
} finally {
|
|
344
|
+
delete software.sendReleaseWarning;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
156
348
|
it('retries an undelivered start notification with a cancel link', async () => {
|
|
157
349
|
const now = new Date();
|
|
158
350
|
const hold = await seed(new Date(now.getTime() + 7 * 24 * HOUR));
|