@learncard/lca-api-service 1.3.1 → 1.4.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.
Files changed (99) hide show
  1. package/.env.example +26 -2
  2. package/AGENTS.md +59 -44
  3. package/CHANGELOG.md +57 -0
  4. package/compose.env +5 -0
  5. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  6. package/dist/app.d.ts +297 -8
  7. package/dist/cache/challenges.d.ts +2 -0
  8. package/dist/cache/recoverySessions.d.ts +42 -0
  9. package/dist/config/environment.d.ts +152 -2
  10. package/dist/config/environment.test.d.ts +1 -0
  11. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  12. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  13. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  14. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  15. package/dist/index.js +2 -2
  16. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  17. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  18. package/dist/models/EscrowHold.d.ts +75 -0
  19. package/dist/models/SigningAuthority.d.ts +7 -1
  20. package/dist/models/UserKey.d.ts +316 -3
  21. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  22. package/dist/models/index.d.ts +1 -0
  23. package/dist/routes/escrow.d.ts +150 -0
  24. package/dist/routes/firebase.test.d.ts +1 -0
  25. package/dist/routes/index.d.ts +6 -0
  26. package/dist/routes/keys.d.ts +181 -7
  27. package/dist/routes/qr-login.d.ts +1 -1
  28. package/dist/services/escrow-enclave/index.d.ts +9 -0
  29. package/dist/services/escrow-enclave/notifications.d.ts +9 -0
  30. package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
  31. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  32. package/dist/services/escrow-enclave/types.d.ts +67 -0
  33. package/dist/types/seed-encryption.d.ts +31 -0
  34. package/dist/types/seed-migration.d.ts +49 -0
  35. package/lambda.ts +8 -3
  36. package/package.json +17 -11
  37. package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
  38. package/scripts/check-signing-authority-redis.mjs +93 -0
  39. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  40. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  41. package/seedMigrationLambda.test.ts +145 -0
  42. package/seedMigrationLambda.ts +50 -0
  43. package/serverless-local.yml +3 -0
  44. package/serverless.escrow-env.cjs +20 -0
  45. package/serverless.yml +133 -0
  46. package/src/accesslayer/signing-authority/create.ts +11 -7
  47. package/src/app.ts +2 -0
  48. package/src/cache/challenges.ts +12 -2
  49. package/src/cache/in-memory-lru.ts +1 -0
  50. package/src/cache/recoverySessions.ts +252 -0
  51. package/src/config/environment.test.ts +116 -0
  52. package/src/config/environment.ts +97 -0
  53. package/src/docker-entry.ts +4 -0
  54. package/src/helpers/learnCard.helpers.test.ts +11 -1
  55. package/src/helpers/learnCard.helpers.ts +57 -45
  56. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  57. package/src/helpers/rateLimit.helpers.ts +112 -0
  58. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  59. package/src/helpers/seedEncryption.helpers.ts +372 -0
  60. package/src/helpers/signingAuthority.helpers.ts +4 -1
  61. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  62. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  63. package/src/models/EscrowHold.ts +225 -0
  64. package/src/models/SigningAuthority.ts +14 -3
  65. package/src/models/UserKey.escrowPin.test.ts +49 -0
  66. package/src/models/UserKey.ts +1024 -40
  67. package/src/models/index.ts +1 -0
  68. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  69. package/src/routes/credentials.ts +8 -0
  70. package/src/routes/escrow.ts +826 -0
  71. package/src/routes/firebase.test.ts +94 -0
  72. package/src/routes/firebase.ts +99 -56
  73. package/src/routes/index.ts +98 -5
  74. package/src/routes/keys.ts +1017 -174
  75. package/src/routes/qr-login.ts +51 -53
  76. package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
  77. package/src/services/escrow-enclave/README.md +51 -0
  78. package/src/services/escrow-enclave/index.ts +36 -0
  79. package/src/services/escrow-enclave/notifications.ts +14 -0
  80. package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
  81. package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
  82. package/src/services/escrow-enclave/types.ts +76 -0
  83. package/src/types/seed-encryption.ts +51 -0
  84. package/src/types/seed-migration.ts +61 -0
  85. package/test/credentials.spec.ts +2 -0
  86. package/test/keys-escrow.spec.ts +1389 -0
  87. package/test/keys-identity-rebind.spec.ts +456 -0
  88. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  89. package/test/keys-recovery-confirmation.spec.ts +586 -0
  90. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  91. package/test/keys-secret-leakage.spec.ts +265 -0
  92. package/test/models/escrow.spec.ts +210 -0
  93. package/test/signing-authority-http.spec.ts +161 -0
  94. package/test/signing-authority-seed-migration.spec.ts +414 -0
  95. package/test/signing-authority.spec.ts +40 -0
  96. package/vitest.config.ts +1 -1
  97. package/vitest.integration.config.ts +5 -0
  98. package/dist/cache/signing-authority.d.ts +0 -6
  99. package/src/cache/signing-authority.ts +0 -37
@@ -0,0 +1,430 @@
1
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import type { Db, Document, Filter } from 'mongodb';
3
+
4
+ import { SIGNING_AUTHORITIES_COLLECTION } from '@models';
5
+ import {
6
+ getSeedIdentity,
7
+ logSeedEncryptionFailure,
8
+ parseSeedEnvelope,
9
+ SeedEncryptionError,
10
+ seedEncryption,
11
+ } from '@helpers/seedEncryption.helpers';
12
+ import type { EncryptedSigningAuthoritySeed } from 'types/seed-encryption';
13
+ import type {
14
+ MigrationSigningAuthority,
15
+ SeedMigrationCounts,
16
+ SeedMigrationReceipt,
17
+ SeedMigrationRequest,
18
+ SeedMigrationResult,
19
+ SeedMigrationState,
20
+ } from 'types/seed-migration';
21
+
22
+ export const SEED_MIGRATION_STATE_COLLECTION = 'signingauthorityseedmigration';
23
+ export const SEED_MIGRATION_RECEIPTS_COLLECTION = 'signingauthorityseedverification';
24
+ const STATE_ID = 'sa-seeds-v1';
25
+ const LEASE_MS = 16 * 60 * 1000; // Longer than the Lambda's maximum lifetime.
26
+ const ENVELOPE_FIELDS = ['encryptedSeed', 'encryptedDek', 'keyVersion'] as const;
27
+ const RECEIPT_FIELDS = [...ENVELOPE_FIELDS, 'ownerDid', 'name', 'did'] as const;
28
+
29
+ export class SeedMigrationError extends Error {
30
+ constructor(public readonly category: string) {
31
+ super(`Signing authority seed migration stopped: ${category}`);
32
+ this.name = 'SeedMigrationError';
33
+ }
34
+ }
35
+
36
+ const hasField = (field: string): Document => ({ $ne: [{ $type: `$${field}` }, 'missing'] });
37
+ const isString = (field: string): Document => ({ $eq: [{ $type: `$${field}` }, 'string'] });
38
+
39
+ /** One aggregation reconciles the collection; cryptographic validity is checked in verify/purge. */
40
+ export const countSeedMigrationDocuments = async (db: Db): Promise<SeedMigrationCounts> => {
41
+ const encrypted = {
42
+ $and: [
43
+ { $in: ['$keyVersion', ['kms-v1', 'local-v1']] },
44
+ isString('encryptedSeed'),
45
+ isString('encryptedDek'),
46
+ { $ne: ['$encryptedSeed', ''] },
47
+ { $ne: ['$encryptedDek', ''] },
48
+ ],
49
+ };
50
+ const legacy = {
51
+ $and: [
52
+ ...ENVELOPE_FIELDS.map(field => ({ $eq: [{ $type: `$${field}` }, 'missing'] })),
53
+ isString('seed'),
54
+ { $ne: ['$seed', ''] },
55
+ ],
56
+ };
57
+ const result = await db
58
+ .collection(SIGNING_AUTHORITIES_COLLECTION)
59
+ .aggregate<SeedMigrationCounts>([
60
+ {
61
+ $group: {
62
+ _id: null,
63
+ total: { $sum: 1 },
64
+ encrypted: { $sum: { $cond: [encrypted, 1, 0] } },
65
+ legacyOnly: { $sum: { $cond: [legacy, 1, 0] } },
66
+ malformed: { $sum: { $cond: [{ $or: [encrypted, legacy] }, 0, 1] } },
67
+ plaintextRemaining: { $sum: { $cond: [hasField('seed'), 1, 0] } },
68
+ },
69
+ },
70
+ { $project: { _id: 0 } },
71
+ ])
72
+ .next();
73
+ return result ?? { total: 0, encrypted: 0, legacyOnly: 0, malformed: 0, plaintextRemaining: 0 };
74
+ };
75
+
76
+ const snapshotFilter = (record: MigrationSigningAuthority): Filter<MigrationSigningAuthority> => {
77
+ const filter: Document = { _id: record._id };
78
+ for (const field of [...RECEIPT_FIELDS, 'seed']) {
79
+ filter[field] = Object.hasOwn(record, field) ? { $eq: record[field] } : { $exists: false };
80
+ }
81
+ return filter;
82
+ };
83
+
84
+ const sameSeed = (actual: string, expected: string): boolean => {
85
+ const left = Buffer.from(actual);
86
+ const right = Buffer.from(expected);
87
+ try {
88
+ return left.length === right.length && timingSafeEqual(left, right);
89
+ } finally {
90
+ left.fill(0);
91
+ right.fill(0);
92
+ }
93
+ };
94
+
95
+ const verifyRecord = async (
96
+ record: MigrationSigningAuthority
97
+ ): Promise<EncryptedSigningAuthoritySeed> => {
98
+ const envelope = parseSeedEnvelope(record);
99
+ if (!envelope) throw new SeedMigrationError('legacy_record_remaining');
100
+ const seed = await seedEncryption.decrypt(envelope, getSeedIdentity(record));
101
+ if (
102
+ Object.hasOwn(record, 'seed') &&
103
+ (typeof record.seed !== 'string' || !sameSeed(seed, record.seed))
104
+ ) {
105
+ throw new SeedMigrationError('seed_mismatch');
106
+ }
107
+ return envelope;
108
+ };
109
+
110
+ const receiptFor = (
111
+ record: MigrationSigningAuthority,
112
+ envelope: EncryptedSigningAuthoritySeed,
113
+ epoch: string
114
+ ): SeedMigrationReceipt => ({
115
+ _id: record._id,
116
+ epoch,
117
+ ...envelope,
118
+ ownerDid: record.ownerDid,
119
+ name: record.name,
120
+ did: record.did ?? null,
121
+ });
122
+
123
+ // Compare receipts with CURRENT documents. The final full sweep catches inserts and changes
124
+ // behind the batch cursor before granting permission to purge.
125
+ const pendingVerification = (epoch: string): Document[] => [
126
+ {
127
+ $lookup: {
128
+ from: SEED_MIGRATION_RECEIPTS_COLLECTION,
129
+ localField: '_id',
130
+ foreignField: '_id',
131
+ as: '_verification',
132
+ },
133
+ },
134
+ {
135
+ $match: {
136
+ $expr: {
137
+ $not: [
138
+ {
139
+ $and: [
140
+ { $eq: [{ $arrayElemAt: ['$_verification.epoch', 0] }, epoch] },
141
+ ...RECEIPT_FIELDS.map(field => ({
142
+ $eq: [
143
+ {
144
+ $ifNull: [
145
+ { $arrayElemAt: [`$_verification.${field}`, 0] },
146
+ null,
147
+ ],
148
+ },
149
+ { $ifNull: [`$${field}`, null] },
150
+ ],
151
+ })),
152
+ ],
153
+ },
154
+ ],
155
+ },
156
+ },
157
+ },
158
+ { $project: { _verification: 0 } },
159
+ ];
160
+
161
+ /** Mongo comparisons are type-bracketed; strings sort before historical ObjectId IDs. */
162
+ const afterCursor = (
163
+ cursor?: MigrationSigningAuthority['_id']
164
+ ): Filter<MigrationSigningAuthority> => {
165
+ if (cursor === undefined) return {};
166
+ if (typeof cursor === 'string') {
167
+ return { $or: [{ _id: { $gt: cursor } }, { _id: { $type: 'objectId' } }] };
168
+ }
169
+ return { _id: { $gt: cursor } };
170
+ };
171
+
172
+ /**
173
+ * Process one idempotent batch. The caller repeats until done. No seed is returned or checkpointed.
174
+ * All writes use majority acknowledgement; purge requires a completed verification epoch.
175
+ */
176
+ export const runSeedMigrationBatch = async (
177
+ db: Db,
178
+ request: SeedMigrationRequest,
179
+ options: { encryptedWritesEnabled: boolean; remainingTime?: () => number }
180
+ ): Promise<SeedMigrationResult> => {
181
+ if (
182
+ !['dry-run', 'prepare', 'verify', 'purge'].includes(request.phase) ||
183
+ (request.batchSize !== undefined &&
184
+ (!Number.isInteger(request.batchSize) ||
185
+ request.batchSize < 1 ||
186
+ request.batchSize > 100))
187
+ ) {
188
+ throw new SeedMigrationError('invalid_request');
189
+ }
190
+ const phase = request.phase;
191
+ const batchSize = request.batchSize ?? 50;
192
+ if (phase === 'dry-run') {
193
+ const result = {
194
+ phase,
195
+ done: true,
196
+ processed: 0,
197
+ counts: await countSeedMigrationDocuments(db),
198
+ countsReconciled: true,
199
+ };
200
+ console.info({ event: 'signing_authority_seed_migration', ...result });
201
+ return result;
202
+ }
203
+ if (!options.encryptedWritesEnabled) throw new SeedMigrationError('encrypted_writes_required');
204
+
205
+ const states = db.collection<SeedMigrationState>(SEED_MIGRATION_STATE_COLLECTION);
206
+ const receipts = db.collection<SeedMigrationReceipt>(SEED_MIGRATION_RECEIPTS_COLLECTION);
207
+ const authorities = db.collection<MigrationSigningAuthority>(SIGNING_AUTHORITIES_COLLECTION);
208
+ const writeConcern = { w: 'majority' as const };
209
+ // Initialize separately so a held lease never makes an upsert overwrite/duplicate the singleton.
210
+ try {
211
+ await states.updateOne(
212
+ { _id: STATE_ID },
213
+ { $setOnInsert: { _id: STATE_ID } },
214
+ { upsert: true, writeConcern }
215
+ );
216
+ } catch {
217
+ throw new SeedMigrationError('checkpoint_unavailable');
218
+ }
219
+ const token = randomUUID();
220
+ const state = await states.findOneAndUpdate(
221
+ {
222
+ _id: STATE_ID,
223
+ $or: [{ leaseOwner: { $exists: false } }, { leaseExpiresAt: { $lte: new Date() } }],
224
+ },
225
+ { $set: { leaseOwner: token, leaseExpiresAt: new Date(Date.now() + LEASE_MS) } },
226
+ {
227
+ returnDocument: 'after',
228
+ writeConcern,
229
+ }
230
+ );
231
+ if (!state) throw new SeedMigrationError('worker_busy');
232
+ const lease = { _id: STATE_ID, leaseOwner: token };
233
+ let current: MigrationSigningAuthority | undefined;
234
+ let processed = 0;
235
+ try {
236
+ const resuming = state.phase === phase && state.status === 'running' && !!state.counts;
237
+ const countsBefore = resuming ? state.counts! : await countSeedMigrationDocuments(db);
238
+ if (countsBefore.malformed) throw new SeedMigrationError('malformed_records');
239
+ if (
240
+ phase === 'purge' &&
241
+ (!state.verifiedEpoch || countsBefore.encrypted !== countsBefore.total)
242
+ ) {
243
+ throw new SeedMigrationError('verification_required');
244
+ }
245
+ const epoch =
246
+ phase === 'verify'
247
+ ? ((state.phase === 'verify' && state.status !== 'failed'
248
+ ? state.epoch
249
+ : undefined) ?? randomUUID())
250
+ : (state.verifiedEpoch ?? '');
251
+ const checkpoint: Partial<SeedMigrationState> = {
252
+ phase,
253
+ status: 'running',
254
+ counts: countsBefore,
255
+ updatedAt: new Date(),
256
+ };
257
+ let cursor = resuming ? state.cursor : undefined;
258
+ if (phase === 'verify') checkpoint.epoch = epoch;
259
+ await states.updateOne(
260
+ lease,
261
+ {
262
+ $set: checkpoint,
263
+ // A killed worker or failed checkpoint write must not leave purge authorized.
264
+ // A successful purge batch restores its epoch so the next batch can resume.
265
+ $unset: { verifiedEpoch: '', ...(!resuming ? { cursor: '' } : {}) },
266
+ },
267
+ { writeConcern }
268
+ );
269
+
270
+ const records =
271
+ phase === 'verify'
272
+ ? await authorities
273
+ .aggregate<MigrationSigningAuthority>([
274
+ { $match: afterCursor(cursor) },
275
+ { $sort: { _id: 1 } },
276
+ ...pendingVerification(epoch),
277
+ { $limit: batchSize },
278
+ ])
279
+ .toArray()
280
+ : await authorities
281
+ .find({
282
+ ...afterCursor(cursor),
283
+ ...(phase === 'prepare'
284
+ ? { keyVersion: { $exists: false } }
285
+ : { seed: { $exists: true } }),
286
+ })
287
+ .sort({ _id: 1 })
288
+ .limit(batchSize)
289
+ .toArray();
290
+
291
+ for (const record of records) {
292
+ if (options.remainingTime && options.remainingTime() < 15_000) break;
293
+ current = record;
294
+ if (phase === 'prepare') {
295
+ if (parseSeedEnvelope(record) || typeof record.seed !== 'string') {
296
+ throw new SeedMigrationError('invalid_legacy_record');
297
+ }
298
+ const envelope = await seedEncryption.encrypt(record.seed, getSeedIdentity(record));
299
+ const updated = await authorities.updateOne(
300
+ snapshotFilter(record),
301
+ { $set: envelope },
302
+ { writeConcern }
303
+ );
304
+ if (updated.matchedCount !== 1) throw new SeedMigrationError('document_changed');
305
+ const persisted = await authorities.findOne(
306
+ snapshotFilter({ ...record, ...envelope })
307
+ );
308
+ if (!persisted) throw new SeedMigrationError('document_changed');
309
+ await verifyRecord(persisted);
310
+ } else if (phase === 'verify') {
311
+ const envelope = await verifyRecord(record);
312
+ if (
313
+ !(await authorities.findOne(snapshotFilter(record), { projection: { _id: 1 } }))
314
+ ) {
315
+ throw new SeedMigrationError('document_changed');
316
+ }
317
+ await receipts.replaceOne(
318
+ { _id: record._id },
319
+ receiptFor(record, envelope, epoch),
320
+ {
321
+ upsert: true,
322
+ writeConcern,
323
+ }
324
+ );
325
+ } else {
326
+ const envelope = parseSeedEnvelope(record);
327
+ if (!envelope || !(await receipts.findOne(receiptFor(record, envelope, epoch)))) {
328
+ throw new SeedMigrationError('reverification_required');
329
+ }
330
+ await verifyRecord(record);
331
+ const updated = await authorities.updateOne(
332
+ snapshotFilter(record),
333
+ { $unset: { seed: '' } },
334
+ { writeConcern }
335
+ );
336
+ if (updated.matchedCount !== 1) throw new SeedMigrationError('document_changed');
337
+ }
338
+ processed += 1;
339
+ cursor = record._id;
340
+ }
341
+
342
+ // Avoid collection-wide reconciliation on every batch. Reconcile after exhausting
343
+ // the ordered scan; an interrupted/time-limited batch continues at its last success.
344
+ const countsReconciled = processed === records.length && records.length < batchSize;
345
+ let counts = countsBefore;
346
+ let done = false;
347
+ let rescanRequired = false;
348
+ if (countsReconciled) {
349
+ counts = await countSeedMigrationDocuments(db);
350
+ if (counts.malformed || (phase !== 'prepare' && counts.encrypted !== counts.total)) {
351
+ throw new SeedMigrationError('reconciliation_failed');
352
+ }
353
+ if (phase === 'verify') {
354
+ rescanRequired = Boolean(
355
+ await authorities
356
+ .aggregate([...pendingVerification(epoch), { $limit: 1 }])
357
+ .next()
358
+ );
359
+ } else {
360
+ rescanRequired =
361
+ phase === 'prepare' ? counts.legacyOnly > 0 : counts.plaintextRemaining > 0;
362
+ }
363
+ done = !rescanRequired;
364
+ cursor = undefined;
365
+ }
366
+ await states.updateOne(
367
+ lease,
368
+ {
369
+ $set: {
370
+ counts,
371
+ status: done ? 'complete' : 'running',
372
+ updatedAt: new Date(),
373
+ ...(cursor !== undefined ? { cursor } : {}),
374
+ ...((phase === 'verify' && done) || phase === 'purge'
375
+ ? { verifiedEpoch: epoch }
376
+ : {}),
377
+ },
378
+ $inc: { processed },
379
+ ...(cursor === undefined ? { $unset: { cursor: '' } } : {}),
380
+ },
381
+ { writeConcern }
382
+ );
383
+ const result = { phase, done, processed, counts, countsReconciled, rescanRequired };
384
+ console.info({ event: 'signing_authority_seed_migration', ...result });
385
+ return result;
386
+ } catch (error) {
387
+ // Clear the purge gate even after partial success; rerun verify before continuing purge.
388
+ const failure = new SeedMigrationError(
389
+ error instanceof SeedMigrationError
390
+ ? error.category
391
+ : error instanceof SeedEncryptionError
392
+ ? error.category
393
+ : 'operation_failed'
394
+ );
395
+ try {
396
+ await states.updateOne(
397
+ lease,
398
+ {
399
+ $set: { status: 'failed', updatedAt: new Date() },
400
+ $unset: { verifiedEpoch: '', cursor: '' },
401
+ },
402
+ { writeConcern }
403
+ );
404
+ } catch (checkpointError) {
405
+ logSeedEncryptionFailure(checkpointError, 'migration_failure_checkpoint', current);
406
+ }
407
+ if (error instanceof SeedMigrationError) {
408
+ console.error({
409
+ event: 'signing_authority_seed_migration_failure',
410
+ operation: `migration_${phase}`,
411
+ recordId: current?._id.toString(),
412
+ category: failure.category,
413
+ });
414
+ } else {
415
+ logSeedEncryptionFailure(error, `migration_${phase}`, current);
416
+ }
417
+ throw failure;
418
+ } finally {
419
+ try {
420
+ await states.updateOne(
421
+ lease,
422
+ { $unset: { leaseOwner: '', leaseExpiresAt: '' } },
423
+ { writeConcern }
424
+ );
425
+ } catch (releaseError) {
426
+ // Preserve the operation's result/error. The lease expires after 16 minutes.
427
+ logSeedEncryptionFailure(releaseError, 'migration_lease_release', current);
428
+ }
429
+ }
430
+ };
@@ -0,0 +1,70 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+
8
+ const script = fileURLToPath(
9
+ new URL('../../scripts/migrate-signing-authority-seeds.mjs', import.meta.url)
10
+ );
11
+ let directory: string;
12
+
13
+ beforeEach(() => {
14
+ directory = mkdtempSync(join(tmpdir(), 'sa-cli-test-'));
15
+ });
16
+ afterEach(() => rmSync(directory, { recursive: true, force: true }));
17
+
18
+ const invoke = () =>
19
+ spawnSync(
20
+ process.execPath,
21
+ [script, '--function-name', 'test-migration', '--region', 'us-east-1'],
22
+ {
23
+ encoding: 'utf8',
24
+ env: {
25
+ NODE_ENV: 'test',
26
+ PATH: `${directory}:${process.env.PATH}`,
27
+ SA_CLI_TEST_DIRECTORY: directory,
28
+ },
29
+ }
30
+ );
31
+ const mockAws = (body: string): void => {
32
+ writeFileSync(join(directory, 'aws'), `#!/usr/bin/env node\n${body}`, { mode: 0o700 });
33
+ };
34
+
35
+ describe('seed migration operator CLI', () => {
36
+ it.each(['ExpiredTokenException', 'TooManyRequestsException', 'Read timeout on endpoint URL'])(
37
+ 'shows AWS CLI stderr for %s without dumping stdout or the exec error',
38
+ diagnostic => {
39
+ mockAws(
40
+ `process.stdout.write('do-not-log-stdout'); process.stderr.write(${JSON.stringify(diagnostic)}); process.exit(1);`
41
+ );
42
+ const result = invoke();
43
+ expect(result.status).toBe(1);
44
+ expect(result.stderr).toContain(diagnostic);
45
+ expect(result.stderr).toContain('rerun to resume');
46
+ expect(result.stderr).not.toContain('do-not-log-stdout');
47
+ expect(result.stdout).toBe('');
48
+ }
49
+ );
50
+
51
+ it('continues a requested rescan even when the boundary batch processed zero records', () => {
52
+ mockAws(`
53
+ const fs = require('node:fs');
54
+ const marker = process.env.SA_CLI_TEST_DIRECTORY + '/invoked';
55
+ const done = fs.existsSync(marker);
56
+ fs.writeFileSync(marker, '1');
57
+ fs.writeFileSync(process.argv.at(-1), JSON.stringify({ done, processed: done ? 1 : 0, rescanRequired: !done }));
58
+ process.stdout.write(JSON.stringify({ StatusCode: 200 }));
59
+ `);
60
+ const result = invoke();
61
+ expect(result.status).toBe(0);
62
+ expect(result.stderr).toBe('');
63
+ const batches = result.stdout
64
+ .trim()
65
+ .split('\n')
66
+ .map(line => JSON.parse(line));
67
+ expect(batches).toHaveLength(2);
68
+ expect(batches[1].done).toBe(true);
69
+ });
70
+ });