@learncard/lca-api-service 1.3.2 → 1.5.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 +99 -2
- package/AGENTS.md +155 -44
- package/CHANGELOG.md +84 -0
- package/bootstrapLambda.test.ts +76 -0
- package/compose.env +5 -0
- 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/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +361 -8
- package/dist/cache/challenges.d.ts +2 -0
- 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/cache/recoverySessions.d.ts +42 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +253 -2
- package/dist/config/environment.test.d.ts +1 -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/learnCard.signingAuthority.test.d.ts +1 -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/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.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/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +153 -0
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +326 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +164 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +182 -7
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/qr-login.d.ts +1 -1
- 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 +25 -0
- package/dist/services/escrow-enclave/notifications.d.ts +24 -0
- 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 +27 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +111 -0
- package/dist/types/notifications.d.ts +5 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -112
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +30 -14
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- 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/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -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 +40 -0
- package/seedMigrationLambda.ts +19 -0
- package/serverless-local.yml +28 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +294 -26
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +4 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +159 -0
- package/src/config/environment.ts +164 -0
- 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 +13 -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/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -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/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- 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/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +541 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1109 -40
- package/src/models/index.ts +2 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +1073 -0
- package/src/routes/firebase.ts +90 -48
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1022 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
- package/src/services/escrow-enclave/README.md +122 -0
- 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 +174 -0
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +348 -0
- 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 +450 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
- package/src/services/escrow-enclave/types.ts +122 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/auth-tickets.spec.ts +480 -0
- package/test/credentials.spec.ts +2 -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 +2328 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +572 -0
- 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-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +90 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +25 -3
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { beforeAll, afterAll, beforeEach, describe, it, expect } from 'vitest';
|
|
3
|
+
import { client } from '@mongo';
|
|
4
|
+
import { EscrowEnvelopeValidator } from '@models';
|
|
5
|
+
import vectors from '../../../../../packages/sss-key-manager/src/__fixtures__/escrow-vectors.json';
|
|
6
|
+
import {
|
|
7
|
+
type EscrowHold,
|
|
8
|
+
createUserKeysIndexes,
|
|
9
|
+
getUserKeysCollection,
|
|
10
|
+
upsertUserKeyByAuthProvider,
|
|
11
|
+
setEscrowBlobByAuthProvider,
|
|
12
|
+
clearEscrowByAuthProvider,
|
|
13
|
+
setEscrowOptInByAuthProvider,
|
|
14
|
+
removeRecoveryMethodFromUserKeyByAuthProvider,
|
|
15
|
+
findUserKeyByAuthProvider,
|
|
16
|
+
createEscrowHoldsIndexes,
|
|
17
|
+
getEscrowHoldsCollection,
|
|
18
|
+
createEscrowHold,
|
|
19
|
+
cancelEscrowHold,
|
|
20
|
+
cancelEscrowHoldByCancelToken,
|
|
21
|
+
completeEscrowHold,
|
|
22
|
+
expireStaleEscrowHolds,
|
|
23
|
+
ESCROW_HOLD_STALE_WINDOW_MS,
|
|
24
|
+
generateEscrowResumeToken,
|
|
25
|
+
hashEscrowResumeToken,
|
|
26
|
+
findPendingEscrowHoldByAuthProvider,
|
|
27
|
+
findEscrowHoldById,
|
|
28
|
+
findEscrowHoldsDueForReminder,
|
|
29
|
+
claimEscrowHoldForReminder,
|
|
30
|
+
recordEscrowHoldNotification,
|
|
31
|
+
rotateEscrowCancelToken,
|
|
32
|
+
reserveEscrowPinAttempt,
|
|
33
|
+
refundEscrowPinAttempt,
|
|
34
|
+
rewrapEscrowBlobByAuthProvider,
|
|
35
|
+
findUserKeysWithEscrowBlobKeyId,
|
|
36
|
+
type EscrowBlob,
|
|
37
|
+
type AuthProviderMapping,
|
|
38
|
+
} from '@models';
|
|
39
|
+
|
|
40
|
+
const provider: AuthProviderMapping = { type: 'firebase', id: 'escrow-model-test' };
|
|
41
|
+
const firstVector = vectors.blobs[0];
|
|
42
|
+
if (!firstVector) throw new Error('escrow-vectors.json has no blobs');
|
|
43
|
+
const blob: Omit<EscrowBlob, 'enrollmentEpoch' | 'blobHash'> = {
|
|
44
|
+
envelope: EscrowEnvelopeValidator.parse(firstVector.envelope),
|
|
45
|
+
enclaveKeyId: firstVector.envelope.keyId,
|
|
46
|
+
enclaveMode: 'software',
|
|
47
|
+
measurements: { pcr0: '$literal-data' },
|
|
48
|
+
shareVersion: 1,
|
|
49
|
+
createdAt: new Date(),
|
|
50
|
+
};
|
|
51
|
+
const createHold = (
|
|
52
|
+
releaseAfter = new Date(),
|
|
53
|
+
releasePolicy: 'hold' | 'pin' = 'hold'
|
|
54
|
+
): ReturnType<typeof createEscrowHold> =>
|
|
55
|
+
createEscrowHold({
|
|
56
|
+
holdRecord: {
|
|
57
|
+
hold: {
|
|
58
|
+
holdId: randomUUID(),
|
|
59
|
+
did: 'did:key:test',
|
|
60
|
+
shareVersion: 1,
|
|
61
|
+
blobHash: 'ab'.repeat(32),
|
|
62
|
+
enrollmentEpoch: 1,
|
|
63
|
+
releasePolicy: 'hold',
|
|
64
|
+
clientEphemeralPublicKey: 'public-key',
|
|
65
|
+
createdLo: 0,
|
|
66
|
+
createdHi: 0,
|
|
67
|
+
policyVersion: 1,
|
|
68
|
+
signature: 'test-signature',
|
|
69
|
+
},
|
|
70
|
+
holdDurationMs: 0,
|
|
71
|
+
ledgerSeq: 0,
|
|
72
|
+
},
|
|
73
|
+
authProvider: provider,
|
|
74
|
+
primaryDid: 'did:key:test',
|
|
75
|
+
shareVersion: 1,
|
|
76
|
+
identityProofType: 'auth-token',
|
|
77
|
+
requestedAt: new Date(),
|
|
78
|
+
releaseAfter,
|
|
79
|
+
releasePolicy,
|
|
80
|
+
clientEphemeralPublicKey: 'public-key',
|
|
81
|
+
resumeTokenHash: hashEscrowResumeToken(generateEscrowResumeToken()),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
beforeAll(async () => {
|
|
85
|
+
process.env.IS_E2E_TEST = 'true';
|
|
86
|
+
process.env.SEED ||= 'a'.repeat(64);
|
|
87
|
+
await client.connect();
|
|
88
|
+
await createUserKeysIndexes();
|
|
89
|
+
await createEscrowHoldsIndexes();
|
|
90
|
+
});
|
|
91
|
+
beforeEach(async () => {
|
|
92
|
+
await getUserKeysCollection().deleteMany({ 'authProviders.id': provider.id });
|
|
93
|
+
await getEscrowHoldsCollection().deleteMany({ 'authProvider.id': provider.id });
|
|
94
|
+
await upsertUserKeyByAuthProvider({ type: 'email', value: 'escrow@example.com' }, provider, {
|
|
95
|
+
primaryDid: 'did:key:test',
|
|
96
|
+
authShare: { encryptedData: 'data', encryptedDek: 'dek', iv: 'iv' },
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
afterAll(async () => {
|
|
100
|
+
await client.close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('escrow model invariants', () => {
|
|
104
|
+
it('migrates legacy pending holds before replacing the identity-only unique index', async () => {
|
|
105
|
+
const collection = getEscrowHoldsCollection();
|
|
106
|
+
await collection.createIndex(
|
|
107
|
+
{ 'authProvider.type': 1, 'authProvider.id': 1 },
|
|
108
|
+
{
|
|
109
|
+
name: 'pending_escrow_identity_unique',
|
|
110
|
+
unique: true,
|
|
111
|
+
partialFilterExpression: { status: 'pending' },
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
const hold = await createHold();
|
|
115
|
+
await collection.updateOne({ _id: hold._id }, { $unset: { releasePolicy: '' } });
|
|
116
|
+
expect((await findPendingEscrowHoldByAuthProvider(provider, 'hold'))?._id).toBe(hold._id);
|
|
117
|
+
await createEscrowHoldsIndexes();
|
|
118
|
+
const indexes = await collection.listIndexes().toArray();
|
|
119
|
+
expect(indexes.some(index => index.name === 'pending_escrow_identity_unique')).toBe(false);
|
|
120
|
+
expect(
|
|
121
|
+
indexes.find(index => index.name === 'pending_escrow_identity_policy_unique')
|
|
122
|
+
).toMatchObject({
|
|
123
|
+
key: { 'authProvider.type': 1, 'authProvider.id': 1, releasePolicy: 1 },
|
|
124
|
+
unique: true,
|
|
125
|
+
partialFilterExpression: { status: 'pending' },
|
|
126
|
+
});
|
|
127
|
+
expect((await findPendingEscrowHoldByAuthProvider(provider, 'hold'))?.releasePolicy).toBe(
|
|
128
|
+
'hold'
|
|
129
|
+
);
|
|
130
|
+
const pin = await createHold(new Date(), 'pin');
|
|
131
|
+
expect((await findPendingEscrowHoldByAuthProvider(provider, 'pin'))?._id).toBe(pin._id);
|
|
132
|
+
await expect(createHold()).rejects.toMatchObject({ code: 11000 });
|
|
133
|
+
await expect(createHold(new Date(), 'pin')).rejects.toMatchObject({ code: 11000 });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('refunds only matching active reservations and never makes attempts negative', async () => {
|
|
137
|
+
await setEscrowBlobByAuthProvider(provider, blob, 1, {
|
|
138
|
+
salt: Buffer.alloc(16).toString('base64'),
|
|
139
|
+
});
|
|
140
|
+
await reserveEscrowPinAttempt(provider, 1, blob.envelope.ciphertext);
|
|
141
|
+
await refundEscrowPinAttempt(provider, 2, blob.envelope.ciphertext);
|
|
142
|
+
await refundEscrowPinAttempt(provider, 1, 'replacement');
|
|
143
|
+
expect(
|
|
144
|
+
(await findUserKeyByAuthProvider(provider.type, provider.id))?.escrowPin?.failedAttempts
|
|
145
|
+
).toBe(1);
|
|
146
|
+
await refundEscrowPinAttempt(provider, 1, blob.envelope.ciphertext);
|
|
147
|
+
await refundEscrowPinAttempt(provider, 1, blob.envelope.ciphertext);
|
|
148
|
+
expect(
|
|
149
|
+
(await findUserKeyByAuthProvider(provider.type, provider.id))?.escrowPin?.failedAttempts
|
|
150
|
+
).toBe(0);
|
|
151
|
+
await reserveEscrowPinAttempt(provider, 1, blob.envelope.ciphertext);
|
|
152
|
+
await getUserKeysCollection().updateOne(
|
|
153
|
+
{ 'authProviders.id': provider.id },
|
|
154
|
+
{ $set: { 'escrowPin.disabledAt': new Date() } }
|
|
155
|
+
);
|
|
156
|
+
await refundEscrowPinAttempt(provider, 1, blob.envelope.ciphertext);
|
|
157
|
+
expect(
|
|
158
|
+
(await findUserKeyByAuthProvider(provider.type, provider.id))?.escrowPin?.failedAttempts
|
|
159
|
+
).toBe(1);
|
|
160
|
+
});
|
|
161
|
+
it('validates required opaque hold records before insert', async () => {
|
|
162
|
+
const valid = await createHold();
|
|
163
|
+
expect(valid._id).toBe(valid.holdRecord.hold.holdId);
|
|
164
|
+
await cancelEscrowHold(valid._id, 'did');
|
|
165
|
+
const input = {
|
|
166
|
+
...valid,
|
|
167
|
+
holdRecord: {
|
|
168
|
+
...valid.holdRecord,
|
|
169
|
+
hold: { ...valid.holdRecord.hold, holdId: randomUUID(), signedExtension: 'keep' },
|
|
170
|
+
signedExtension: 'keep',
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
const opaque = await createEscrowHold(input);
|
|
174
|
+
expect(opaque.holdRecord).toEqual(input.holdRecord);
|
|
175
|
+
await cancelEscrowHold(opaque._id, 'did');
|
|
176
|
+
for (const holdRecord of [
|
|
177
|
+
undefined,
|
|
178
|
+
{},
|
|
179
|
+
{ ...valid.holdRecord, ledgerSeq: 'invalid' },
|
|
180
|
+
{ ...valid.holdRecord, hold: { ...valid.holdRecord.hold, enrollmentEpoch: 0 } },
|
|
181
|
+
]) {
|
|
182
|
+
// Simulate untyped/legacy input at the runtime schema boundary.
|
|
183
|
+
await expect(
|
|
184
|
+
createEscrowHold({ ...input, holdRecord } as unknown as Parameters<
|
|
185
|
+
typeof createEscrowHold
|
|
186
|
+
>[0])
|
|
187
|
+
).rejects.toThrow('Invalid escrow hold');
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
it('computes the Rust-order fixed blob hash and increments epochs atomically', async () => {
|
|
191
|
+
const first = await setEscrowBlobByAuthProvider(provider, blob, 1);
|
|
192
|
+
expect(first?.escrowBlob?.enrollmentEpoch).toBe(1);
|
|
193
|
+
expect(first?.escrowBlob?.blobHash).toBe(
|
|
194
|
+
'8090217ef11060bfa380c4a4b26d76a64811216176ecb60749652fabf3b09425'
|
|
195
|
+
);
|
|
196
|
+
const results = await Promise.all([
|
|
197
|
+
setEscrowBlobByAuthProvider(provider, blob, 1),
|
|
198
|
+
setEscrowBlobByAuthProvider(provider, blob, 1),
|
|
199
|
+
]);
|
|
200
|
+
expect(results.map(result => result?.escrowBlob?.enrollmentEpoch).sort()).toEqual([2, 3]);
|
|
201
|
+
expect(results[0]?.escrowBlob?.measurements.pcr0).toBe('$literal-data');
|
|
202
|
+
await expect(
|
|
203
|
+
setEscrowBlobByAuthProvider(
|
|
204
|
+
provider,
|
|
205
|
+
{ ...blob, envelope: { ...blob.envelope, ciphertext: '' } },
|
|
206
|
+
1
|
|
207
|
+
)
|
|
208
|
+
).rejects.toThrow('Invalid escrow payload');
|
|
209
|
+
expect(
|
|
210
|
+
await setEscrowBlobByAuthProvider(provider, { ...blob, enclaveKeyId: 'other' }, 1)
|
|
211
|
+
).toBeNull();
|
|
212
|
+
expect(
|
|
213
|
+
(await findUserKeyByAuthProvider(provider.type, provider.id))?.escrowBlob
|
|
214
|
+
?.enrollmentEpoch
|
|
215
|
+
).toBe(3);
|
|
216
|
+
});
|
|
217
|
+
it('atomically replaces escrow without duplicate methods and preserves literal values', async () => {
|
|
218
|
+
await setEscrowBlobByAuthProvider(provider, blob, 1);
|
|
219
|
+
const result = await setEscrowBlobByAuthProvider(provider, blob, 1);
|
|
220
|
+
expect(result?.recoveryMethods).toHaveLength(1);
|
|
221
|
+
expect(result?.recoveryMethods[0]).toMatchObject({
|
|
222
|
+
type: 'escrow',
|
|
223
|
+
confirmationStatus: 'confirmed',
|
|
224
|
+
shareVersion: 1,
|
|
225
|
+
});
|
|
226
|
+
expect(result?.escrowBlob).toEqual({
|
|
227
|
+
...blob,
|
|
228
|
+
enrollmentEpoch: 2,
|
|
229
|
+
blobHash: expect.stringMatching(/^[0-9a-f]{64}$/),
|
|
230
|
+
});
|
|
231
|
+
expect(
|
|
232
|
+
await setEscrowBlobByAuthProvider(provider, { ...blob, shareVersion: 2 }, 2)
|
|
233
|
+
).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
it('rewraps a previous-key blob onto the current key (P9.3): same epoch and version, escrowPin untouched', async () => {
|
|
236
|
+
const stored = await setEscrowBlobByAuthProvider(provider, blob, 1, {
|
|
237
|
+
salt: Buffer.alloc(16).toString('base64'),
|
|
238
|
+
});
|
|
239
|
+
const oldBlob = stored!.escrowBlob!;
|
|
240
|
+
const oldPin = stored!.escrowPin!;
|
|
241
|
+
const newEnvelope: EscrowBlob['envelope'] = {
|
|
242
|
+
...blob.envelope,
|
|
243
|
+
keyId: 'current',
|
|
244
|
+
ciphertext: Buffer.alloc(32, 1).toString('base64'),
|
|
245
|
+
};
|
|
246
|
+
const rewrapped = await rewrapEscrowBlobByAuthProvider(
|
|
247
|
+
provider,
|
|
248
|
+
oldBlob,
|
|
249
|
+
oldPin,
|
|
250
|
+
newEnvelope,
|
|
251
|
+
{
|
|
252
|
+
enclaveKeyId: 'current',
|
|
253
|
+
enclaveMode: 'software',
|
|
254
|
+
measurements: {},
|
|
255
|
+
}
|
|
256
|
+
);
|
|
257
|
+
expect(rewrapped?.escrowBlob).toEqual({
|
|
258
|
+
envelope: newEnvelope,
|
|
259
|
+
enclaveKeyId: 'current',
|
|
260
|
+
enclaveMode: 'software',
|
|
261
|
+
measurements: {},
|
|
262
|
+
shareVersion: oldBlob.shareVersion,
|
|
263
|
+
enrollmentEpoch: oldBlob.enrollmentEpoch,
|
|
264
|
+
blobHash: expect.stringMatching(/^[0-9a-f]{64}$/),
|
|
265
|
+
createdAt: expect.any(Date),
|
|
266
|
+
});
|
|
267
|
+
expect(rewrapped?.escrowBlob?.blobHash).not.toBe(oldBlob.blobHash);
|
|
268
|
+
// escrowPin is byte-identical to what was read before the rewrap — the
|
|
269
|
+
// model never touches it, and the CAS filter proves it wasn't touched
|
|
270
|
+
// by anything else concurrently either.
|
|
271
|
+
expect(rewrapped?.escrowPin).toEqual(oldPin);
|
|
272
|
+
// Lost race: presenting the now-stale oldBlob/oldPin again is a no-op,
|
|
273
|
+
// not an error — a later job run should simply re-observe and retry.
|
|
274
|
+
expect(
|
|
275
|
+
await rewrapEscrowBlobByAuthProvider(provider, oldBlob, oldPin, newEnvelope, {
|
|
276
|
+
enclaveKeyId: 'current',
|
|
277
|
+
enclaveMode: 'software',
|
|
278
|
+
measurements: {},
|
|
279
|
+
})
|
|
280
|
+
).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
it('rewrapEscrowBlobByAuthProvider: concurrent runs never double-write; a mid-run re-enroll wins the race', async () => {
|
|
283
|
+
const stored = await setEscrowBlobByAuthProvider(provider, blob, 1, {
|
|
284
|
+
salt: Buffer.alloc(16).toString('base64'),
|
|
285
|
+
});
|
|
286
|
+
const oldBlob = stored!.escrowBlob!;
|
|
287
|
+
const oldPin = stored!.escrowPin!;
|
|
288
|
+
const attempt = (ciphertext: string): ReturnType<typeof rewrapEscrowBlobByAuthProvider> =>
|
|
289
|
+
rewrapEscrowBlobByAuthProvider(
|
|
290
|
+
provider,
|
|
291
|
+
oldBlob,
|
|
292
|
+
oldPin,
|
|
293
|
+
{ ...blob.envelope, keyId: 'current', ciphertext },
|
|
294
|
+
{ enclaveKeyId: 'current', enclaveMode: 'software', measurements: {} }
|
|
295
|
+
);
|
|
296
|
+
const [first, second] = await Promise.all([
|
|
297
|
+
attempt(Buffer.alloc(32, 2).toString('base64')),
|
|
298
|
+
attempt(Buffer.alloc(32, 3).toString('base64')),
|
|
299
|
+
]);
|
|
300
|
+
expect([first, second].filter(result => result !== null)).toHaveLength(1);
|
|
301
|
+
// A concurrent re-enrollment (a NEW escrowBlob, different from oldBlob)
|
|
302
|
+
// between read and write also wins the race against a stale rewrap.
|
|
303
|
+
const reenrolled = await setEscrowBlobByAuthProvider(provider, blob, 1);
|
|
304
|
+
expect(reenrolled?.escrowBlob?.envelope.keyId).toBe(blob.enclaveKeyId);
|
|
305
|
+
expect(
|
|
306
|
+
await rewrapEscrowBlobByAuthProvider(
|
|
307
|
+
provider,
|
|
308
|
+
oldBlob,
|
|
309
|
+
oldPin,
|
|
310
|
+
{
|
|
311
|
+
...blob.envelope,
|
|
312
|
+
keyId: 'current',
|
|
313
|
+
ciphertext: Buffer.alloc(32, 4).toString('base64'),
|
|
314
|
+
},
|
|
315
|
+
{ enclaveKeyId: 'current', enclaveMode: 'software', measurements: {} }
|
|
316
|
+
)
|
|
317
|
+
).toBeNull();
|
|
318
|
+
});
|
|
319
|
+
it('findUserKeysWithEscrowBlobKeyId: matches given keyIds, excludes opted-out accounts', async () => {
|
|
320
|
+
const stored = await setEscrowBlobByAuthProvider(provider, blob, 1);
|
|
321
|
+
expect(
|
|
322
|
+
(await findUserKeysWithEscrowBlobKeyId([blob.enclaveKeyId], 10)).map(
|
|
323
|
+
u => u.authProviders[0]?.id
|
|
324
|
+
)
|
|
325
|
+
).toContain(provider.id);
|
|
326
|
+
expect(await findUserKeysWithEscrowBlobKeyId(['nonexistent-key'], 10)).toEqual([]);
|
|
327
|
+
await clearEscrowByAuthProvider(provider, { optOut: true });
|
|
328
|
+
await getUserKeysCollection().updateOne(
|
|
329
|
+
{ 'authProviders.id': provider.id },
|
|
330
|
+
{ $set: { escrowBlob: stored!.escrowBlob! } }
|
|
331
|
+
);
|
|
332
|
+
expect(
|
|
333
|
+
(await findUserKeysWithEscrowBlobKeyId([blob.enclaveKeyId], 10)).some(
|
|
334
|
+
u => u.authProviders[0]?.id === provider.id
|
|
335
|
+
)
|
|
336
|
+
).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
it('clears with and without opting out; explicit opt-in permits enrollment again', async () => {
|
|
339
|
+
await setEscrowBlobByAuthProvider(provider, blob, 1);
|
|
340
|
+
const cleared = await clearEscrowByAuthProvider(provider, { optOut: false });
|
|
341
|
+
expect(cleared?.escrowBlob).toBeUndefined();
|
|
342
|
+
expect(cleared?.escrowOptedOutAt).toBeUndefined();
|
|
343
|
+
expect(cleared?.recoveryMethods).toEqual([]);
|
|
344
|
+
await setEscrowBlobByAuthProvider(provider, blob, 1);
|
|
345
|
+
const optedOut = await clearEscrowByAuthProvider(provider, { optOut: true });
|
|
346
|
+
expect(optedOut?.escrowOptedOutAt).toBeInstanceOf(Date);
|
|
347
|
+
expect(await setEscrowBlobByAuthProvider(provider, blob, 1)).toBeNull();
|
|
348
|
+
await clearEscrowByAuthProvider(provider, { optOut: false });
|
|
349
|
+
expect(await setEscrowBlobByAuthProvider(provider, blob, 1)).toBeNull();
|
|
350
|
+
await setEscrowOptInByAuthProvider(provider);
|
|
351
|
+
expect(await setEscrowBlobByAuthProvider(provider, blob, 1)).not.toBeNull();
|
|
352
|
+
});
|
|
353
|
+
it('removes blobs on explicit method deletion and history pruning', async () => {
|
|
354
|
+
await setEscrowBlobByAuthProvider(provider, blob, 1);
|
|
355
|
+
await removeRecoveryMethodFromUserKeyByAuthProvider(provider, 'escrow');
|
|
356
|
+
expect(
|
|
357
|
+
(await findUserKeyByAuthProvider(provider.type, provider.id))?.escrowBlob
|
|
358
|
+
).toBeUndefined();
|
|
359
|
+
await setEscrowBlobByAuthProvider(provider, blob, 1);
|
|
360
|
+
for (let i = 0; i < 6; i++) {
|
|
361
|
+
await upsertUserKeyByAuthProvider(
|
|
362
|
+
{ type: 'email', value: 'escrow@example.com' },
|
|
363
|
+
provider,
|
|
364
|
+
{ authShare: { encryptedData: 'next', encryptedDek: 'dek', iv: 'iv' } }
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const result = await findUserKeyByAuthProvider(provider.type, provider.id);
|
|
368
|
+
expect(result?.escrowBlob).toBeUndefined();
|
|
369
|
+
expect(result?.recoveryMethods).toEqual([]);
|
|
370
|
+
});
|
|
371
|
+
it('allows only one pending hold and terminal transitions are conditional', async () => {
|
|
372
|
+
const hold = await createHold();
|
|
373
|
+
await expect(createHold()).rejects.toMatchObject({ code: 11000 });
|
|
374
|
+
expect((await cancelEscrowHold(hold._id, 'did'))?.status).toBe('cancelled');
|
|
375
|
+
expect(await cancelEscrowHold(hold._id, 'system')).toBeNull();
|
|
376
|
+
expect(await completeEscrowHold(hold._id)).toBeNull();
|
|
377
|
+
const next = await createHold();
|
|
378
|
+
expect((await completeEscrowHold(next._id))?.status).toBe('completed');
|
|
379
|
+
expect(await completeEscrowHold(next._id)).toBeNull();
|
|
380
|
+
expect(await cancelEscrowHold(next._id, 'did')).toBeNull();
|
|
381
|
+
});
|
|
382
|
+
it('expires only stale pending holds and creates random hashed resume tokens', async () => {
|
|
383
|
+
const now = new Date();
|
|
384
|
+
const hold = await createHold(new Date(now.getTime() - ESCROW_HOLD_STALE_WINDOW_MS - 1));
|
|
385
|
+
// Claiming must enforce expiry even before lazy cleanup has run.
|
|
386
|
+
expect(await completeEscrowHold(hold._id)).toBeNull();
|
|
387
|
+
expect(await expireStaleEscrowHolds(now)).toBe(1);
|
|
388
|
+
expect(await completeEscrowHold(hold._id)).toBeNull();
|
|
389
|
+
expect(await cancelEscrowHold(hold._id, 'did')).toBeNull();
|
|
390
|
+
const token = generateEscrowResumeToken();
|
|
391
|
+
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
|
392
|
+
expect(generateEscrowResumeToken()).not.toBe(token);
|
|
393
|
+
expect(hashEscrowResumeToken(token)).toMatch(/^[0-9a-f]{64}$/);
|
|
394
|
+
expect(hashEscrowResumeToken(token)).not.toBe(token);
|
|
395
|
+
});
|
|
396
|
+
it('cancels via link token exactly once, rejecting wrong, reused, or hash-less holds', async () => {
|
|
397
|
+
const cancelTokenHash = 'a'.repeat(64);
|
|
398
|
+
const hold = await createEscrowHold({
|
|
399
|
+
holdRecord: {
|
|
400
|
+
hold: {
|
|
401
|
+
holdId: randomUUID(),
|
|
402
|
+
did: 'did:key:test',
|
|
403
|
+
shareVersion: 1,
|
|
404
|
+
blobHash: 'ab'.repeat(32),
|
|
405
|
+
enrollmentEpoch: 1,
|
|
406
|
+
releasePolicy: 'hold',
|
|
407
|
+
clientEphemeralPublicKey: 'public-key',
|
|
408
|
+
createdLo: 0,
|
|
409
|
+
createdHi: 0,
|
|
410
|
+
policyVersion: 1,
|
|
411
|
+
signature: 'test-signature',
|
|
412
|
+
},
|
|
413
|
+
holdDurationMs: 0,
|
|
414
|
+
ledgerSeq: 0,
|
|
415
|
+
},
|
|
416
|
+
authProvider: provider,
|
|
417
|
+
primaryDid: 'did:key:test',
|
|
418
|
+
shareVersion: 1,
|
|
419
|
+
identityProofType: 'auth-token',
|
|
420
|
+
requestedAt: new Date(),
|
|
421
|
+
releaseAfter: new Date(),
|
|
422
|
+
releasePolicy: 'hold',
|
|
423
|
+
clientEphemeralPublicKey: 'public-key',
|
|
424
|
+
resumeTokenHash: hashEscrowResumeToken(generateEscrowResumeToken()),
|
|
425
|
+
cancelTokenHash,
|
|
426
|
+
});
|
|
427
|
+
expect(await cancelEscrowHoldByCancelToken(hold._id, 'b'.repeat(64))).toBeNull();
|
|
428
|
+
const cancelled = await cancelEscrowHoldByCancelToken(hold._id, cancelTokenHash);
|
|
429
|
+
expect(cancelled).toMatchObject({ status: 'cancelled', cancelledBy: 'link' });
|
|
430
|
+
expect(cancelled?.cancelTokenUsedAt).toBeInstanceOf(Date);
|
|
431
|
+
expect(cancelled?.cancelReason).toBeUndefined();
|
|
432
|
+
// Burned: the hash still matches, but cancelTokenUsedAt no longer $exists-fails the filter.
|
|
433
|
+
expect(await cancelEscrowHoldByCancelToken(hold._id, cancelTokenHash)).toBeNull();
|
|
434
|
+
// A pending hold with no cancelTokenHash at all never matches an exact-hash filter.
|
|
435
|
+
const noToken = await createHold();
|
|
436
|
+
expect(await cancelEscrowHoldByCancelToken(noToken._id, cancelTokenHash)).toBeNull();
|
|
437
|
+
});
|
|
438
|
+
it('records notifications by appending to the array without disturbing other fields', async () => {
|
|
439
|
+
const hold = await createHold();
|
|
440
|
+
await recordEscrowHoldNotification(hold._id, 'started');
|
|
441
|
+
let stored = await findEscrowHoldById(hold._id);
|
|
442
|
+
expect(stored?.notifications).toHaveLength(1);
|
|
443
|
+
expect(stored?.notifications[0]).toMatchObject({ kind: 'started' });
|
|
444
|
+
expect(stored?.notifications[0]?.sentAt).toBeInstanceOf(Date);
|
|
445
|
+
expect(stored?.status).toBe('pending');
|
|
446
|
+
await recordEscrowHoldNotification(hold._id, 'cancelled');
|
|
447
|
+
stored = await findEscrowHoldById(hold._id);
|
|
448
|
+
expect(stored?.notifications.map(entry => entry.kind)).toEqual(['started', 'cancelled']);
|
|
449
|
+
// A missing hold id is a no-op, never a throw.
|
|
450
|
+
await expect(
|
|
451
|
+
recordEscrowHoldNotification('does-not-exist', 'completed')
|
|
452
|
+
).resolves.toBeUndefined();
|
|
453
|
+
});
|
|
454
|
+
it('rotates the cancel token only for pending holds, leaving terminal holds untouched', async () => {
|
|
455
|
+
const originalHash = 'a'.repeat(64);
|
|
456
|
+
const hold = await createEscrowHold({
|
|
457
|
+
holdRecord: {
|
|
458
|
+
hold: {
|
|
459
|
+
holdId: randomUUID(),
|
|
460
|
+
did: 'did:key:test',
|
|
461
|
+
shareVersion: 1,
|
|
462
|
+
blobHash: 'ab'.repeat(32),
|
|
463
|
+
enrollmentEpoch: 1,
|
|
464
|
+
releasePolicy: 'hold',
|
|
465
|
+
clientEphemeralPublicKey: 'public-key',
|
|
466
|
+
createdLo: 0,
|
|
467
|
+
createdHi: 0,
|
|
468
|
+
policyVersion: 1,
|
|
469
|
+
signature: 'test-signature',
|
|
470
|
+
},
|
|
471
|
+
holdDurationMs: 0,
|
|
472
|
+
ledgerSeq: 0,
|
|
473
|
+
},
|
|
474
|
+
authProvider: provider,
|
|
475
|
+
primaryDid: 'did:key:test',
|
|
476
|
+
shareVersion: 1,
|
|
477
|
+
identityProofType: 'auth-token',
|
|
478
|
+
requestedAt: new Date(),
|
|
479
|
+
releaseAfter: new Date(),
|
|
480
|
+
releasePolicy: 'hold',
|
|
481
|
+
clientEphemeralPublicKey: 'public-key',
|
|
482
|
+
resumeTokenHash: hashEscrowResumeToken(generateEscrowResumeToken()),
|
|
483
|
+
cancelTokenHash: originalHash,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const token = await rotateEscrowCancelToken(hold._id);
|
|
487
|
+
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
|
488
|
+
const rotated = await findEscrowHoldById(hold._id);
|
|
489
|
+
expect(rotated?.cancelTokenHash).toMatch(/^[0-9a-f]{64}$/);
|
|
490
|
+
expect(rotated?.cancelTokenHash).toBe(originalHash);
|
|
491
|
+
expect(rotated?.cancelTokenHashes).toHaveLength(1);
|
|
492
|
+
expect(await rotateEscrowCancelToken(hold._id)).toBe(token);
|
|
493
|
+
expect((await findEscrowHoldById(hold._id))?.cancelTokenHashes).toHaveLength(1);
|
|
494
|
+
|
|
495
|
+
// Cancelling burns the pending-only precondition: further rotation is a no-op.
|
|
496
|
+
const cancelled = await cancelEscrowHold(hold._id, 'did');
|
|
497
|
+
const hashAfterCancel = cancelled?.cancelTokenHash;
|
|
498
|
+
expect(await rotateEscrowCancelToken(hold._id)).toBeNull();
|
|
499
|
+
expect((await findEscrowHoldById(hold._id))?.cancelTokenHash).toBe(hashAfterCancel);
|
|
500
|
+
|
|
501
|
+
const completedHold = await createHold();
|
|
502
|
+
await completeEscrowHold(completedHold._id);
|
|
503
|
+
expect(await rotateEscrowCancelToken(completedHold._id)).toBeNull();
|
|
504
|
+
expect((await findEscrowHoldById(completedHold._id))?.cancelTokenHash).toBeUndefined();
|
|
505
|
+
|
|
506
|
+
expect(await rotateEscrowCancelToken('does-not-exist')).toBeNull();
|
|
507
|
+
});
|
|
508
|
+
it('finds only pending hold-policy holds due within 24h and claims each at most once', async () => {
|
|
509
|
+
const now = new Date();
|
|
510
|
+
const hour = 60 * 60 * 1000;
|
|
511
|
+
// Each candidate needs its own authProvider: the partial unique index
|
|
512
|
+
// allows only one pending hold-policy hold per (authProvider, policy).
|
|
513
|
+
const createHoldFor = (releaseAfter: Date, releasePolicy: 'hold' | 'pin' = 'hold') =>
|
|
514
|
+
createEscrowHold({
|
|
515
|
+
holdRecord: {
|
|
516
|
+
hold: {
|
|
517
|
+
holdId: randomUUID(),
|
|
518
|
+
did: 'did:key:test',
|
|
519
|
+
shareVersion: 1,
|
|
520
|
+
blobHash: 'ab'.repeat(32),
|
|
521
|
+
enrollmentEpoch: 1,
|
|
522
|
+
releasePolicy: 'hold',
|
|
523
|
+
clientEphemeralPublicKey: 'public-key',
|
|
524
|
+
createdLo: 0,
|
|
525
|
+
createdHi: 0,
|
|
526
|
+
policyVersion: 1,
|
|
527
|
+
signature: 'test-signature',
|
|
528
|
+
},
|
|
529
|
+
holdDurationMs: 0,
|
|
530
|
+
ledgerSeq: 0,
|
|
531
|
+
},
|
|
532
|
+
authProvider: { type: 'firebase', id: `escrow-reminder-${randomUUID()}` },
|
|
533
|
+
primaryDid: 'did:key:test',
|
|
534
|
+
shareVersion: 1,
|
|
535
|
+
identityProofType: 'auth-token',
|
|
536
|
+
requestedAt: new Date(),
|
|
537
|
+
releaseAfter,
|
|
538
|
+
releasePolicy,
|
|
539
|
+
clientEphemeralPublicKey: 'public-key',
|
|
540
|
+
resumeTokenHash: hashEscrowResumeToken(generateEscrowResumeToken()),
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
const dueSoon = await createHoldFor(new Date(now.getTime() + 23 * hour));
|
|
544
|
+
const dueLate = await createHoldFor(new Date(now.getTime() + 25 * hour));
|
|
545
|
+
const alreadyReminded = await createHoldFor(new Date(now.getTime() + hour));
|
|
546
|
+
await recordEscrowHoldNotification(alreadyReminded._id, 'reminder');
|
|
547
|
+
const pinHold = await createHoldFor(now, 'pin');
|
|
548
|
+
const completedHold = await createHoldFor(new Date(now.getTime() + 2 * hour));
|
|
549
|
+
await completeEscrowHold(completedHold._id);
|
|
550
|
+
|
|
551
|
+
const due = await findEscrowHoldsDueForReminder(now, 200);
|
|
552
|
+
const dueIds = due.map(hold => hold._id);
|
|
553
|
+
expect(dueIds).toContain(dueSoon._id);
|
|
554
|
+
expect(dueIds).not.toContain(dueLate._id);
|
|
555
|
+
expect(dueIds).not.toContain(alreadyReminded._id);
|
|
556
|
+
expect(dueIds).not.toContain(pinHold._id);
|
|
557
|
+
expect(dueIds).not.toContain(completedHold._id);
|
|
558
|
+
|
|
559
|
+
// Two concurrent claims for the same hold: MongoDB serializes the
|
|
560
|
+
// findOneAndUpdate CAS, so exactly one observes the pre-push state.
|
|
561
|
+
const [first, second] = await Promise.all([
|
|
562
|
+
claimEscrowHoldForReminder(dueSoon._id, now),
|
|
563
|
+
claimEscrowHoldForReminder(dueSoon._id, now),
|
|
564
|
+
]);
|
|
565
|
+
const winners = [first, second].filter((claim): claim is EscrowHold => claim !== null);
|
|
566
|
+
expect(winners).toHaveLength(1);
|
|
567
|
+
expect(winners[0]?.notifications.filter(entry => entry.kind === 'reminder')).toHaveLength(
|
|
568
|
+
0
|
|
569
|
+
);
|
|
570
|
+
expect(await claimEscrowHoldForReminder(dueSoon._id, now)).toBeNull();
|
|
571
|
+
});
|
|
572
|
+
});
|
|
@@ -390,6 +390,10 @@ describe('Notifications', () => {
|
|
|
390
390
|
|
|
391
391
|
it('records exactly one E2E push attempt per delivery window', async () => {
|
|
392
392
|
vi.stubEnv('IS_E2E_TEST', 'true');
|
|
393
|
+
// Earlier notification cases also write E2E attempts into this cache.
|
|
394
|
+
// Isolate this window-count assertion just as we clean up afterward.
|
|
395
|
+
const previousKeys = await cache.keys('e2e:push-attempt:*');
|
|
396
|
+
if (previousKeys?.length) await cache.delete(previousKeys);
|
|
393
397
|
|
|
394
398
|
try {
|
|
395
399
|
await expect(sendRefresh('window-1', 1)).resolves.toBe(true);
|