@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,132 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const collection = vi.hoisted(() => ({
|
|
4
|
+
indexes: vi.fn(),
|
|
5
|
+
dropIndex: vi.fn(async () => undefined),
|
|
6
|
+
createIndex: vi.fn(async () => 'index'),
|
|
7
|
+
findOneAndUpdate: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('@mongo', () => ({ default: { collection: () => collection } }));
|
|
11
|
+
|
|
12
|
+
const createAuthSubjectIndexes = async (): Promise<void> => {
|
|
13
|
+
const model = await import('../../src/models/AuthSubject');
|
|
14
|
+
await model.createAuthSubjectIndexes();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.resetModules();
|
|
19
|
+
collection.indexes.mockReset().mockResolvedValue([{ name: '_id_', key: { _id: 1 } }]);
|
|
20
|
+
collection.dropIndex.mockReset().mockResolvedValue(undefined);
|
|
21
|
+
collection.createIndex.mockReset().mockResolvedValue('index');
|
|
22
|
+
collection.findOneAndUpdate.mockReset().mockResolvedValue({ subject: 'subject' });
|
|
23
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => vi.restoreAllMocks());
|
|
26
|
+
|
|
27
|
+
describe('createAuthSubjectIndexes', () => {
|
|
28
|
+
it('indexes subject without uniqueness so linked sign-in methods can share it', async () => {
|
|
29
|
+
await createAuthSubjectIndexes();
|
|
30
|
+
|
|
31
|
+
expect(collection.createIndex).toHaveBeenCalledWith({ subject: 1 });
|
|
32
|
+
expect(collection.createIndex).not.toHaveBeenCalledWith(
|
|
33
|
+
{ subject: 1 },
|
|
34
|
+
expect.objectContaining({ unique: true })
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('keeps identityKey unique', async () => {
|
|
39
|
+
await createAuthSubjectIndexes();
|
|
40
|
+
|
|
41
|
+
expect(collection.createIndex).toHaveBeenCalledWith({ identityKey: 1 }, { unique: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('drops a pre-existing unique subject index before recreating it', async () => {
|
|
45
|
+
collection.indexes.mockResolvedValue([
|
|
46
|
+
{ name: '_id_', key: { _id: 1 } },
|
|
47
|
+
{ name: 'subject_1', key: { subject: 1 }, unique: true },
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
await createAuthSubjectIndexes();
|
|
51
|
+
|
|
52
|
+
expect(collection.dropIndex).toHaveBeenCalledWith('subject_1');
|
|
53
|
+
expect(collection.dropIndex.mock.invocationCallOrder[0]).toBeLessThan(
|
|
54
|
+
collection.createIndex.mock.invocationCallOrder[0] ?? Infinity
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('leaves a non-unique subject index alone', async () => {
|
|
59
|
+
collection.indexes.mockResolvedValue([{ name: 'subject_1', key: { subject: 1 } }]);
|
|
60
|
+
|
|
61
|
+
await createAuthSubjectIndexes();
|
|
62
|
+
|
|
63
|
+
expect(collection.dropIndex).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('treats a missing collection as having no indexes', async () => {
|
|
67
|
+
collection.indexes.mockRejectedValue(Object.assign(new Error('ns'), { code: 26 }));
|
|
68
|
+
|
|
69
|
+
await createAuthSubjectIndexes();
|
|
70
|
+
|
|
71
|
+
expect(collection.createIndex).toHaveBeenCalledTimes(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('tolerates the legacy index disappearing concurrently', async () => {
|
|
75
|
+
collection.indexes.mockResolvedValue([
|
|
76
|
+
{ name: 'subject_1', key: { subject: 1 }, unique: true },
|
|
77
|
+
]);
|
|
78
|
+
collection.dropIndex.mockRejectedValueOnce(Object.assign(new Error('gone'), { code: 27 }));
|
|
79
|
+
|
|
80
|
+
await expect(createAuthSubjectIndexes()).resolves.toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('AuthSubject index readiness', () => {
|
|
85
|
+
it.each(['subject', 'identityKey'])(
|
|
86
|
+
'rejects all waiters when %s indexing fails, then retries',
|
|
87
|
+
async failedIndex => {
|
|
88
|
+
const { createIndex, findOneAndUpdate } = collection;
|
|
89
|
+
const { ensureAuthSubjectIndexes, getOrCreateAuthSubject } =
|
|
90
|
+
await import('../../src/models/AuthSubject');
|
|
91
|
+
const failure = new Error('index unavailable');
|
|
92
|
+
let rejectIndex!: (error: Error) => void;
|
|
93
|
+
const pendingIndex = new Promise<string>((_resolve, reject) => {
|
|
94
|
+
rejectIndex = reject;
|
|
95
|
+
});
|
|
96
|
+
if (failedIndex === 'identityKey') createIndex.mockResolvedValueOnce('subject');
|
|
97
|
+
createIndex.mockImplementationOnce(() => pendingIndex);
|
|
98
|
+
|
|
99
|
+
const ready = ensureAuthSubjectIndexes();
|
|
100
|
+
expect(ensureAuthSubjectIndexes()).toBe(ready);
|
|
101
|
+
const waiters = Promise.allSettled([
|
|
102
|
+
ready,
|
|
103
|
+
getOrCreateAuthSubject('email:test@example.com', { emailVerified: true }),
|
|
104
|
+
getOrCreateAuthSubject('email:test@example.com', { emailVerified: true }),
|
|
105
|
+
]);
|
|
106
|
+
rejectIndex(failure);
|
|
107
|
+
expect(await waiters).toEqual(
|
|
108
|
+
Array.from({ length: 3 }, () => ({ status: 'rejected', reason: failure }))
|
|
109
|
+
);
|
|
110
|
+
expect(findOneAndUpdate).not.toHaveBeenCalled();
|
|
111
|
+
|
|
112
|
+
createIndex.mockClear();
|
|
113
|
+
const completed: string[] = [];
|
|
114
|
+
createIndex.mockImplementation(async (key: Record<string, number>) => {
|
|
115
|
+
completed.push(Object.keys(key)[0]!);
|
|
116
|
+
return 'index';
|
|
117
|
+
});
|
|
118
|
+
findOneAndUpdate.mockImplementation(async () => {
|
|
119
|
+
expect(completed).toEqual(['subject', 'identityKey']);
|
|
120
|
+
return { subject: 'subject' };
|
|
121
|
+
});
|
|
122
|
+
await getOrCreateAuthSubject('email:test@example.com', { emailVerified: true });
|
|
123
|
+
expect(createIndex.mock.calls).toEqual([
|
|
124
|
+
[{ subject: 1 }],
|
|
125
|
+
[{ identityKey: 1 }, { unique: true }],
|
|
126
|
+
]);
|
|
127
|
+
expect(findOneAndUpdate).toHaveBeenCalledOnce();
|
|
128
|
+
await ensureAuthSubjectIndexes();
|
|
129
|
+
expect(createIndex).toHaveBeenCalledTimes(2);
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
1
2
|
import { beforeAll, afterAll, beforeEach, describe, it, expect } from 'vitest';
|
|
2
3
|
import { client } from '@mongo';
|
|
4
|
+
import { EscrowEnvelopeValidator } from '@models';
|
|
5
|
+
import vectors from '../../../../../packages/sss-key-manager/src/__fixtures__/escrow-vectors.json';
|
|
3
6
|
import {
|
|
7
|
+
type EscrowHold,
|
|
4
8
|
createUserKeysIndexes,
|
|
5
9
|
getUserKeysCollection,
|
|
6
10
|
upsertUserKeyByAuthProvider,
|
|
@@ -13,32 +17,34 @@ import {
|
|
|
13
17
|
getEscrowHoldsCollection,
|
|
14
18
|
createEscrowHold,
|
|
15
19
|
cancelEscrowHold,
|
|
20
|
+
cancelEscrowHoldByCancelToken,
|
|
16
21
|
completeEscrowHold,
|
|
17
22
|
expireStaleEscrowHolds,
|
|
18
23
|
ESCROW_HOLD_STALE_WINDOW_MS,
|
|
19
24
|
generateEscrowResumeToken,
|
|
20
25
|
hashEscrowResumeToken,
|
|
21
26
|
findPendingEscrowHoldByAuthProvider,
|
|
27
|
+
findEscrowHoldById,
|
|
28
|
+
findEscrowHoldsDueForReminder,
|
|
29
|
+
claimEscrowHoldForReminder,
|
|
30
|
+
recordEscrowHoldNotification,
|
|
31
|
+
rotateEscrowCancelToken,
|
|
22
32
|
reserveEscrowPinAttempt,
|
|
23
33
|
refundEscrowPinAttempt,
|
|
34
|
+
rewrapEscrowBlobByAuthProvider,
|
|
35
|
+
findUserKeysWithEscrowBlobKeyId,
|
|
24
36
|
type EscrowBlob,
|
|
25
37
|
type AuthProviderMapping,
|
|
26
38
|
} from '@models';
|
|
27
39
|
|
|
28
40
|
const provider: AuthProviderMapping = { type: 'firebase', id: 'escrow-model-test' };
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
ephemeralPublicKey: 'key',
|
|
35
|
-
salt: 'salt',
|
|
36
|
-
iv: 'iv',
|
|
37
|
-
ciphertext: '$literal-data',
|
|
38
|
-
},
|
|
39
|
-
enclaveKeyId: '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,
|
|
40
46
|
enclaveMode: 'software',
|
|
41
|
-
measurements: {},
|
|
47
|
+
measurements: { pcr0: '$literal-data' },
|
|
42
48
|
shareVersion: 1,
|
|
43
49
|
createdAt: new Date(),
|
|
44
50
|
};
|
|
@@ -47,6 +53,23 @@ const createHold = (
|
|
|
47
53
|
releasePolicy: 'hold' | 'pin' = 'hold'
|
|
48
54
|
): ReturnType<typeof createEscrowHold> =>
|
|
49
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
|
+
},
|
|
50
73
|
authProvider: provider,
|
|
51
74
|
primaryDid: 'did:key:test',
|
|
52
75
|
shareVersion: 1,
|
|
@@ -135,6 +158,62 @@ describe('escrow model invariants', () => {
|
|
|
135
158
|
(await findUserKeyByAuthProvider(provider.type, provider.id))?.escrowPin?.failedAttempts
|
|
136
159
|
).toBe(1);
|
|
137
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
|
+
});
|
|
138
217
|
it('atomically replaces escrow without duplicate methods and preserves literal values', async () => {
|
|
139
218
|
await setEscrowBlobByAuthProvider(provider, blob, 1);
|
|
140
219
|
const result = await setEscrowBlobByAuthProvider(provider, blob, 1);
|
|
@@ -144,11 +223,118 @@ describe('escrow model invariants', () => {
|
|
|
144
223
|
confirmationStatus: 'confirmed',
|
|
145
224
|
shareVersion: 1,
|
|
146
225
|
});
|
|
147
|
-
expect(result?.escrowBlob).toEqual(
|
|
226
|
+
expect(result?.escrowBlob).toEqual({
|
|
227
|
+
...blob,
|
|
228
|
+
enrollmentEpoch: 2,
|
|
229
|
+
blobHash: expect.stringMatching(/^[0-9a-f]{64}$/),
|
|
230
|
+
});
|
|
148
231
|
expect(
|
|
149
232
|
await setEscrowBlobByAuthProvider(provider, { ...blob, shareVersion: 2 }, 2)
|
|
150
233
|
).toBeNull();
|
|
151
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
|
+
});
|
|
152
338
|
it('clears with and without opting out; explicit opt-in permits enrollment again', async () => {
|
|
153
339
|
await setEscrowBlobByAuthProvider(provider, blob, 1);
|
|
154
340
|
const cleared = await clearEscrowByAuthProvider(provider, { optOut: false });
|
|
@@ -207,4 +393,180 @@ describe('escrow model invariants', () => {
|
|
|
207
393
|
expect(hashEscrowResumeToken(token)).toMatch(/^[0-9a-f]{64}$/);
|
|
208
394
|
expect(hashEscrowResumeToken(token)).not.toBe(token);
|
|
209
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
|
+
});
|
|
210
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);
|