@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,419 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { ObjectId, type Collection } from 'mongodb';
|
|
5
|
+
import { client } from '../src/mongo';
|
|
6
|
+
import { getUserKeysCollection, type MongoUserKeyType } from '../src/models/UserKey';
|
|
7
|
+
import { getAuthSubjectsCollection, getOrCreateAuthSubject } from '../src/models/AuthSubject';
|
|
8
|
+
import { maskEmail } from '../src/helpers/maskEmail';
|
|
9
|
+
import { createKeycloakAdmin } from './keycloak-admin';
|
|
10
|
+
import { setTimeout } from 'node:timers/promises';
|
|
11
|
+
import {
|
|
12
|
+
fetchFirebaseUsers,
|
|
13
|
+
findProviderLinkConflicts,
|
|
14
|
+
linkFirebaseProviders,
|
|
15
|
+
type FirebaseReader,
|
|
16
|
+
type FirebaseUser,
|
|
17
|
+
type ProviderLinkSummary,
|
|
18
|
+
} from './keycloak-provider-links';
|
|
19
|
+
|
|
20
|
+
export interface ProvisionOptions {
|
|
21
|
+
apply?: boolean;
|
|
22
|
+
email?: string;
|
|
23
|
+
limit?: number;
|
|
24
|
+
after?: string;
|
|
25
|
+
linkProviders?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface ProvisionSummary extends ProviderLinkSummary {
|
|
28
|
+
processed: number;
|
|
29
|
+
skipped: number;
|
|
30
|
+
refused: number;
|
|
31
|
+
mapped: number;
|
|
32
|
+
missingMappings: number;
|
|
33
|
+
}
|
|
34
|
+
const log = (message: string): void => {
|
|
35
|
+
process.stdout.write(`${message}\n`);
|
|
36
|
+
};
|
|
37
|
+
const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
|
|
39
|
+
type ProvisionKey = Omit<MongoUserKeyType, '_id'> & { _id: ObjectId };
|
|
40
|
+
|
|
41
|
+
const loadProvisioningBatch = async (
|
|
42
|
+
source: AsyncIterator<ProvisionKey>,
|
|
43
|
+
firebase?: FirebaseReader
|
|
44
|
+
): Promise<{ batch: ProvisionKey[]; firebaseUsers?: Map<string, FirebaseUser> }> => {
|
|
45
|
+
const batch: ProvisionKey[] = [];
|
|
46
|
+
for (let index = 0; index < (firebase ? 100 : 1); index++) {
|
|
47
|
+
const item = await source.next();
|
|
48
|
+
if (item.done) break;
|
|
49
|
+
batch.push(item.value);
|
|
50
|
+
}
|
|
51
|
+
const firebaseUsers =
|
|
52
|
+
firebase && batch.length
|
|
53
|
+
? await fetchFirebaseUsers(
|
|
54
|
+
batch.flatMap(key =>
|
|
55
|
+
key.authProviders
|
|
56
|
+
.filter(provider => provider.type === 'firebase')
|
|
57
|
+
.map(provider => provider.id)
|
|
58
|
+
),
|
|
59
|
+
firebase
|
|
60
|
+
)
|
|
61
|
+
: undefined;
|
|
62
|
+
return { batch, firebaseUsers };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const getFirebaseReader = async (): Promise<FirebaseReader> => {
|
|
66
|
+
const { environment } = await import('../src/config/environment');
|
|
67
|
+
// Validate before importing the legacy initializer, which logs raw JSON parse errors.
|
|
68
|
+
try {
|
|
69
|
+
z.object({})
|
|
70
|
+
.passthrough()
|
|
71
|
+
.parse(JSON.parse(environment.GOOGLE_APPLICATION_CREDENTIAL ?? ''));
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error('Valid existing Firebase Admin credential JSON is required');
|
|
74
|
+
}
|
|
75
|
+
const { default: app } = await import('../src/firebase');
|
|
76
|
+
if (!app)
|
|
77
|
+
throw new Error('Existing Firebase Admin credentials are required for provider linking');
|
|
78
|
+
return app.auth();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Offline operator migration, never a runtime email-based identity fallback. */
|
|
82
|
+
export const provisionKeycloakUsers = async (
|
|
83
|
+
options: ProvisionOptions = {},
|
|
84
|
+
dependencies: {
|
|
85
|
+
firebase?: FirebaseReader;
|
|
86
|
+
admin?: Awaited<ReturnType<typeof createKeycloakAdmin>>;
|
|
87
|
+
} = {}
|
|
88
|
+
): Promise<ProvisionSummary> => {
|
|
89
|
+
const email =
|
|
90
|
+
options.email === undefined
|
|
91
|
+
? undefined
|
|
92
|
+
: z.email().parse(options.email.trim().toLowerCase());
|
|
93
|
+
const limit =
|
|
94
|
+
options.limit === undefined ? undefined : z.number().int().positive().parse(options.limit);
|
|
95
|
+
const after =
|
|
96
|
+
options.after === undefined
|
|
97
|
+
? undefined
|
|
98
|
+
: new ObjectId(
|
|
99
|
+
z
|
|
100
|
+
.string()
|
|
101
|
+
.regex(/^[0-9a-fA-F]{24}$/)
|
|
102
|
+
.parse(options.after)
|
|
103
|
+
);
|
|
104
|
+
let lastProcessedId = after?.toHexString();
|
|
105
|
+
const summary: ProvisionSummary = {
|
|
106
|
+
processed: 0,
|
|
107
|
+
skipped: 0,
|
|
108
|
+
refused: 0,
|
|
109
|
+
mapped: 0,
|
|
110
|
+
missingMappings: 0,
|
|
111
|
+
linked: 0,
|
|
112
|
+
alreadyLinked: 0,
|
|
113
|
+
conflicts: 0,
|
|
114
|
+
noSocialProvider: 0,
|
|
115
|
+
wouldLink: 0,
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
const linkProviders = options.linkProviders ?? true;
|
|
119
|
+
const firebase = linkProviders
|
|
120
|
+
? (dependencies.firebase ?? (await getFirebaseReader()))
|
|
121
|
+
: undefined;
|
|
122
|
+
const admin = dependencies.admin ?? (await createKeycloakAdmin());
|
|
123
|
+
// Mongo assigns ObjectIds on insert; the model's optional string _id describes serialized data.
|
|
124
|
+
const keys = getUserKeysCollection() as unknown as Collection<
|
|
125
|
+
Omit<MongoUserKeyType, '_id'>
|
|
126
|
+
>;
|
|
127
|
+
const subjects = getAuthSubjectsCollection();
|
|
128
|
+
const cursor = keys
|
|
129
|
+
.find({
|
|
130
|
+
'authProviders.type': 'firebase',
|
|
131
|
+
...(after ? { _id: { $gt: after } } : {}),
|
|
132
|
+
...(email
|
|
133
|
+
? {
|
|
134
|
+
'contactMethod.type': 'email',
|
|
135
|
+
'contactMethod.value': {
|
|
136
|
+
$regex: `^${escapeRegex(email)}$`,
|
|
137
|
+
$options: 'i',
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
141
|
+
})
|
|
142
|
+
.sort({ _id: 1 });
|
|
143
|
+
if (limit) cursor.limit(limit);
|
|
144
|
+
log(`Mode: ${options.apply ? 'APPLY' : 'DRY RUN (no resource writes)'}\nContact\tResult`);
|
|
145
|
+
const iterator = cursor[Symbol.asyncIterator]();
|
|
146
|
+
try {
|
|
147
|
+
while (true) {
|
|
148
|
+
const { batch, firebaseUsers } = await loadProvisioningBatch(iterator, firebase);
|
|
149
|
+
if (!batch.length) break;
|
|
150
|
+
for (const key of batch) {
|
|
151
|
+
let failed = false;
|
|
152
|
+
try {
|
|
153
|
+
summary.processed++;
|
|
154
|
+
if (key.contactMethod.type !== 'email') {
|
|
155
|
+
summary.skipped++;
|
|
156
|
+
log('[phone-only]\tSKIP: phone OTP deferred');
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const normalized = key.contactMethod.value.trim().toLowerCase();
|
|
160
|
+
const label = maskEmail(normalized);
|
|
161
|
+
const refuse = (reason: string): void => {
|
|
162
|
+
summary.refused++;
|
|
163
|
+
log(`${label}\tREFUSED: ${reason}`);
|
|
164
|
+
};
|
|
165
|
+
if (!z.email().safeParse(normalized).success) {
|
|
166
|
+
refuse('invalid email');
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const firebaseIds = key.authProviders
|
|
170
|
+
.filter(provider => provider.type === 'firebase')
|
|
171
|
+
.map(provider => provider.id);
|
|
172
|
+
const firebaseRecords = firebaseIds.map(id => firebaseUsers?.get(id));
|
|
173
|
+
if (
|
|
174
|
+
linkProviders &&
|
|
175
|
+
(!firebaseIds.length ||
|
|
176
|
+
firebaseRecords.some(
|
|
177
|
+
record =>
|
|
178
|
+
!record ||
|
|
179
|
+
record.disabled ||
|
|
180
|
+
!record.emailVerified ||
|
|
181
|
+
record.email?.trim().toLowerCase() !== normalized
|
|
182
|
+
))
|
|
183
|
+
) {
|
|
184
|
+
refuse(
|
|
185
|
+
'Firebase UID missing, disabled, unverified, or email ownership differs'
|
|
186
|
+
);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const linkSocial = async (userId?: string): Promise<void> => {
|
|
190
|
+
if (!linkProviders) return;
|
|
191
|
+
await linkFirebaseProviders({
|
|
192
|
+
userId,
|
|
193
|
+
providers: firebaseRecords.flatMap(
|
|
194
|
+
record => record?.providerData ?? []
|
|
195
|
+
),
|
|
196
|
+
apply: options.apply ?? false,
|
|
197
|
+
admin,
|
|
198
|
+
log: message => log(`${label}\t${message}`),
|
|
199
|
+
summary,
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
// Contact indexes are deliberately nonunique; recycled/ambiguous addresses need manual review.
|
|
203
|
+
const peers = await keys.countDocuments({
|
|
204
|
+
'contactMethod.type': 'email',
|
|
205
|
+
'contactMethod.value': {
|
|
206
|
+
$regex: `^\\s*${escapeRegex(normalized)}\\s*$`,
|
|
207
|
+
$options: 'i',
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
if (peers !== 1) {
|
|
211
|
+
refuse('ambiguous UserKey email; manual identity review required');
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const identityKey = `email:${normalized}`;
|
|
215
|
+
let subject = await subjects.findOne({ identityKey });
|
|
216
|
+
const matches = await admin.findUsers(normalized);
|
|
217
|
+
if (matches.length > 1) {
|
|
218
|
+
refuse('ambiguous Keycloak email');
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
let user = matches[0];
|
|
222
|
+
if (
|
|
223
|
+
user &&
|
|
224
|
+
(user.email?.toLowerCase() !== normalized ||
|
|
225
|
+
!user.enabled ||
|
|
226
|
+
!user.emailVerified)
|
|
227
|
+
) {
|
|
228
|
+
refuse('existing Keycloak user is disabled or email is not verified');
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const links = user ? await admin.links(user.id) : [];
|
|
232
|
+
if (linkProviders) {
|
|
233
|
+
const conflicts = findProviderLinkConflicts(
|
|
234
|
+
firebaseRecords.flatMap(record => record?.providerData ?? []),
|
|
235
|
+
links
|
|
236
|
+
);
|
|
237
|
+
if (conflicts.length) {
|
|
238
|
+
summary.conflicts += conflicts.length;
|
|
239
|
+
refuse(
|
|
240
|
+
`${conflicts.join(', ')} identity differs; manual review required`
|
|
241
|
+
);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const link = links.find(item => item.identityProvider === 'lca-api');
|
|
246
|
+
if (link && link.userId !== subject?.subject) {
|
|
247
|
+
refuse('existing lca-api link differs');
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const mappings = key.authProviders.filter(
|
|
251
|
+
provider => provider.type === 'keycloak'
|
|
252
|
+
);
|
|
253
|
+
if (mappings.some(mapping => mapping.id !== user?.id)) {
|
|
254
|
+
refuse('existing keycloak mapping differs');
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
user &&
|
|
259
|
+
(await keys.findOne({
|
|
260
|
+
_id: { $ne: key._id },
|
|
261
|
+
authProviders: { $elemMatch: { type: 'keycloak', id: user.id } },
|
|
262
|
+
}))
|
|
263
|
+
) {
|
|
264
|
+
refuse('Keycloak identity already belongs to another UserKey');
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const actions = [
|
|
268
|
+
!subject && 'create subject',
|
|
269
|
+
!user && 'create user',
|
|
270
|
+
!link && 'link lca-api',
|
|
271
|
+
!mappings.length && 'append mapping',
|
|
272
|
+
].filter(Boolean);
|
|
273
|
+
if (!options.apply) {
|
|
274
|
+
log(
|
|
275
|
+
`${label}\t${actions.length ? `PLAN: ${actions.join(', ')}` : 'unchanged'}`
|
|
276
|
+
);
|
|
277
|
+
await linkSocial(user?.id);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// Do not call getOrCreate for an existing subject: it updates lastLoginAt.
|
|
281
|
+
subject ??= await getOrCreateAuthSubject(identityKey, {
|
|
282
|
+
email: normalized,
|
|
283
|
+
emailVerified: true,
|
|
284
|
+
});
|
|
285
|
+
if (!user) {
|
|
286
|
+
const phone = z
|
|
287
|
+
.object({
|
|
288
|
+
phone: z.string().optional(),
|
|
289
|
+
phoneNumber: z.string().optional(),
|
|
290
|
+
})
|
|
291
|
+
.parse(key);
|
|
292
|
+
const number = phone.phone ?? phone.phoneNumber;
|
|
293
|
+
await admin.request('/users', {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
body: JSON.stringify({
|
|
296
|
+
username: normalized,
|
|
297
|
+
email: normalized,
|
|
298
|
+
emailVerified: true,
|
|
299
|
+
enabled: true,
|
|
300
|
+
...(number
|
|
301
|
+
? {
|
|
302
|
+
attributes: {
|
|
303
|
+
phone_number: [number],
|
|
304
|
+
// UserKey has no canonical secondary-phone verification proof.
|
|
305
|
+
// Never promote a legacy string into a verified identity claim.
|
|
306
|
+
phone_number_verified: ['false'],
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
: {}),
|
|
310
|
+
}),
|
|
311
|
+
});
|
|
312
|
+
const created = await admin.findUsers(normalized);
|
|
313
|
+
if (created.length !== 1)
|
|
314
|
+
throw new Error('Cannot resolve newly provisioned user');
|
|
315
|
+
user = created[0]!;
|
|
316
|
+
}
|
|
317
|
+
if (!link)
|
|
318
|
+
await admin.request(
|
|
319
|
+
`/users/${encodeURIComponent(user.id)}/federated-identity/lca-api`,
|
|
320
|
+
{
|
|
321
|
+
method: 'POST',
|
|
322
|
+
body: JSON.stringify({
|
|
323
|
+
identityProvider: 'lca-api',
|
|
324
|
+
userId: subject.subject,
|
|
325
|
+
userName: subject.subject,
|
|
326
|
+
}),
|
|
327
|
+
}
|
|
328
|
+
);
|
|
329
|
+
if (!mappings.length) {
|
|
330
|
+
const result = await keys.updateOne(
|
|
331
|
+
{
|
|
332
|
+
_id: key._id,
|
|
333
|
+
authProviders: { $not: { $elemMatch: { type: 'keycloak' } } },
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
$addToSet: { authProviders: { type: 'keycloak', id: user.id } },
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
if (result.modifiedCount !== 1)
|
|
340
|
+
throw new Error('Concurrent mapping change; rerun after review');
|
|
341
|
+
summary.mapped++;
|
|
342
|
+
}
|
|
343
|
+
await linkSocial(user.id);
|
|
344
|
+
log(`${label}\t${actions.length ? actions.join(', ') : 'unchanged'}`);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
failed = true;
|
|
347
|
+
throw error;
|
|
348
|
+
} finally {
|
|
349
|
+
if (!failed) {
|
|
350
|
+
lastProcessedId = key._id.toHexString();
|
|
351
|
+
if (summary.processed % 100 === 0)
|
|
352
|
+
log(`Last processed id: ${lastProcessedId}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (firebase && batch.length === 100) await setTimeout(1000);
|
|
357
|
+
}
|
|
358
|
+
} finally {
|
|
359
|
+
await iterator.return?.();
|
|
360
|
+
}
|
|
361
|
+
summary.missingMappings = await keys.countDocuments({
|
|
362
|
+
'contactMethod.type': 'email',
|
|
363
|
+
'authProviders.type': { $ne: 'keycloak' },
|
|
364
|
+
});
|
|
365
|
+
log(
|
|
366
|
+
`Mapping-presence assertion: ${summary.missingMappings === 0 && summary.refused === 0 ? 'PASS' : 'FAIL'} — ${summary.missingMappings} email UserKeys lack a keycloak mapping (global, not limited by --email/--limit).`
|
|
367
|
+
);
|
|
368
|
+
log(
|
|
369
|
+
'Cutover approval: NOT EVALUATED. Presence is necessary, not sufficient; validate all existing mappings and authoritative Firebase UID ownership before cutover.'
|
|
370
|
+
);
|
|
371
|
+
return summary;
|
|
372
|
+
} finally {
|
|
373
|
+
log(`Summary: ${JSON.stringify(summary)}`);
|
|
374
|
+
log(
|
|
375
|
+
`Last processed id: ${lastProcessedId ?? '(none)'}${lastProcessedId ? `; resume with --after ${lastProcessedId}` : ''}`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
381
|
+
try {
|
|
382
|
+
const { values } = parseArgs({
|
|
383
|
+
args: process.argv.slice(2).filter(arg => arg !== '--'),
|
|
384
|
+
options: {
|
|
385
|
+
apply: { type: 'boolean', default: false },
|
|
386
|
+
email: { type: 'string' },
|
|
387
|
+
limit: { type: 'string' },
|
|
388
|
+
after: { type: 'string' },
|
|
389
|
+
'link-providers': { type: 'boolean', default: true },
|
|
390
|
+
},
|
|
391
|
+
allowNegative: true,
|
|
392
|
+
strict: true,
|
|
393
|
+
});
|
|
394
|
+
const summary = await provisionKeycloakUsers({
|
|
395
|
+
apply: values.apply,
|
|
396
|
+
email: values.email,
|
|
397
|
+
after: values.after,
|
|
398
|
+
linkProviders: values['link-providers'],
|
|
399
|
+
limit: values.limit === undefined ? undefined : Number(values.limit),
|
|
400
|
+
});
|
|
401
|
+
if (summary.refused || summary.conflicts) process.exitCode = 1;
|
|
402
|
+
} catch (error) {
|
|
403
|
+
// Argument errors are safe to echo; driver/HTTP errors can embed connection
|
|
404
|
+
// strings or credentials, so only their type is shown.
|
|
405
|
+
const code = (error as { code?: unknown }).code;
|
|
406
|
+
const detail =
|
|
407
|
+
typeof code === 'string' && code.startsWith('ERR_PARSE_ARGS') && error instanceof Error
|
|
408
|
+
? error.message
|
|
409
|
+
: `${error instanceof Error ? error.name : typeof error}${
|
|
410
|
+
typeof code === 'string' ? ` (${code})` : ''
|
|
411
|
+
}`;
|
|
412
|
+
process.stderr.write(
|
|
413
|
+
`Provisioning failed: ${detail}\nCheck arguments, service connectivity and admin permissions; rerun the dry-run before applying.\n`
|
|
414
|
+
);
|
|
415
|
+
process.exitCode = 1;
|
|
416
|
+
} finally {
|
|
417
|
+
await client.close();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { Context } from 'aws-lambda';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { SeedMigrationResult } from './src/types/seed-migration';
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
connect: vi.fn(),
|
|
7
|
+
close: vi.fn(),
|
|
8
|
+
runBatch: vi.fn(),
|
|
9
|
+
mongodb: {},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('./src/mongo', () => ({
|
|
13
|
+
client: { connect: mocks.connect, close: mocks.close },
|
|
14
|
+
mongodb: mocks.mongodb,
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('./src/config/environment', () => ({
|
|
17
|
+
environment: { SA_SEED_ENCRYPT_WRITES: true },
|
|
18
|
+
}));
|
|
19
|
+
vi.mock('./src/migrations/signingAuthoritySeeds', async importOriginal => ({
|
|
20
|
+
...(await importOriginal<typeof import('./src/migrations/signingAuthoritySeeds')>()),
|
|
21
|
+
runSeedMigrationBatch: mocks.runBatch,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { handler } from './seedMigrationApp';
|
|
25
|
+
import { SeedMigrationError } from './src/migrations/signingAuthoritySeeds';
|
|
26
|
+
|
|
27
|
+
const result: SeedMigrationResult = {
|
|
28
|
+
phase: 'prepare',
|
|
29
|
+
done: false,
|
|
30
|
+
processed: 1,
|
|
31
|
+
counts: { total: 2, encrypted: 1, legacyOnly: 1, malformed: 0, plaintextRemaining: 2 },
|
|
32
|
+
countsReconciled: false,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const context = (): Context => ({
|
|
36
|
+
callbackWaitsForEmptyEventLoop: true,
|
|
37
|
+
functionName: 'seedMigration',
|
|
38
|
+
functionVersion: '$LATEST',
|
|
39
|
+
invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:seedMigration',
|
|
40
|
+
memoryLimitInMB: '1024',
|
|
41
|
+
awsRequestId: 'test-request',
|
|
42
|
+
logGroupName: 'test-group',
|
|
43
|
+
logStreamName: 'test-stream',
|
|
44
|
+
getRemainingTimeInMillis: () => 60_000,
|
|
45
|
+
done: vi.fn(),
|
|
46
|
+
fail: vi.fn(),
|
|
47
|
+
succeed: vi.fn(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
mocks.connect.mockReset().mockResolvedValue(undefined);
|
|
52
|
+
mocks.close.mockReset().mockResolvedValue(undefined);
|
|
53
|
+
mocks.runBatch.mockReset().mockResolvedValue(result);
|
|
54
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => vi.restoreAllMocks());
|
|
58
|
+
|
|
59
|
+
describe('seed migration Lambda connection lifecycle', () => {
|
|
60
|
+
it('waits for cleanup before returning the batch result', async () => {
|
|
61
|
+
let finishClose!: () => void;
|
|
62
|
+
mocks.close.mockReturnValue(new Promise<void>(resolve => (finishClose = resolve)));
|
|
63
|
+
const resolved = vi.fn();
|
|
64
|
+
const lambdaContext = context();
|
|
65
|
+
const invocation = handler({ phase: 'prepare', batchSize: 1 }, lambdaContext).then(
|
|
66
|
+
resolved
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
await vi.waitFor(() => expect(mocks.close).toHaveBeenCalledOnce());
|
|
70
|
+
expect(resolved).not.toHaveBeenCalled();
|
|
71
|
+
expect(mocks.runBatch).toHaveBeenCalledWith(
|
|
72
|
+
mocks.mongodb,
|
|
73
|
+
{ phase: 'prepare', batchSize: 1 },
|
|
74
|
+
{ encryptedWritesEnabled: true, remainingTime: expect.any(Function) }
|
|
75
|
+
);
|
|
76
|
+
expect(lambdaContext.callbackWaitsForEmptyEventLoop).toBe(false);
|
|
77
|
+
|
|
78
|
+
finishClose();
|
|
79
|
+
await invocation;
|
|
80
|
+
expect(resolved).toHaveBeenCalledWith(result);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('connects and closes for each sequential warm invocation', async () => {
|
|
84
|
+
await handler({ phase: 'prepare' }, context());
|
|
85
|
+
await handler({ phase: 'prepare' }, context());
|
|
86
|
+
expect(mocks.connect).toHaveBeenCalledTimes(2);
|
|
87
|
+
expect(mocks.runBatch).toHaveBeenCalledTimes(2);
|
|
88
|
+
expect(mocks.close).toHaveBeenCalledTimes(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('closes after a connection failure without returning connection details', async () => {
|
|
92
|
+
mocks.connect.mockRejectedValue(new Error('mongodb://user:secret@private-host'));
|
|
93
|
+
await expect(handler({ phase: 'prepare' }, context())).rejects.toMatchObject({
|
|
94
|
+
category: 'operation_failed',
|
|
95
|
+
message: 'Signing authority seed migration stopped: operation_failed',
|
|
96
|
+
});
|
|
97
|
+
expect(mocks.runBatch).not.toHaveBeenCalled();
|
|
98
|
+
expect(mocks.close).toHaveBeenCalledOnce();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('closes after an unexpected batch failure without returning query details', async () => {
|
|
102
|
+
mocks.runBatch.mockRejectedValue(new Error('sensitive seed query'));
|
|
103
|
+
await expect(handler({ phase: 'prepare' }, context())).rejects.toMatchObject({
|
|
104
|
+
category: 'operation_failed',
|
|
105
|
+
message: 'Signing authority seed migration stopped: operation_failed',
|
|
106
|
+
});
|
|
107
|
+
expect(mocks.close).toHaveBeenCalledOnce();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('preserves the migration failure when cleanup also fails', async () => {
|
|
111
|
+
mocks.runBatch.mockRejectedValue(new SeedMigrationError('verification_required'));
|
|
112
|
+
mocks.close.mockRejectedValue(new Error('sensitive cleanup detail'));
|
|
113
|
+
await expect(handler({ phase: 'purge' }, context())).rejects.toMatchObject({
|
|
114
|
+
category: 'verification_required',
|
|
115
|
+
});
|
|
116
|
+
expect(mocks.close).toHaveBeenCalledOnce();
|
|
117
|
+
expect(console.error).toHaveBeenCalledExactlyOnceWith({
|
|
118
|
+
event: 'signing_authority_seed_migration_failure',
|
|
119
|
+
operation: 'migration_connection_cleanup',
|
|
120
|
+
category: 'operation_failed',
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('sanitizes cleanup errors after an otherwise successful batch', async () => {
|
|
125
|
+
mocks.close.mockRejectedValue(new Error('mongodb://user:secret@private-host'));
|
|
126
|
+
await expect(handler({ phase: 'prepare' }, context())).rejects.toMatchObject({
|
|
127
|
+
category: 'operation_failed',
|
|
128
|
+
message: 'Signing authority seed migration stopped: operation_failed',
|
|
129
|
+
});
|
|
130
|
+
expect(console.error).toHaveBeenCalledExactlyOnceWith({
|
|
131
|
+
event: 'signing_authority_seed_migration_failure',
|
|
132
|
+
operation: 'migration_connection_cleanup',
|
|
133
|
+
category: 'operation_failed',
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('rejects invalid requests before touching the database', async () => {
|
|
138
|
+
await expect(handler({ phase: 'unknown' }, context())).rejects.toMatchObject({
|
|
139
|
+
category: 'invalid_request',
|
|
140
|
+
});
|
|
141
|
+
expect(mocks.connect).not.toHaveBeenCalled();
|
|
142
|
+
expect(mocks.runBatch).not.toHaveBeenCalled();
|
|
143
|
+
expect(mocks.close).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Context } from 'aws-lambda';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { environment } from './src/config/environment';
|
|
4
|
+
import { client, mongodb } from './src/mongo';
|
|
5
|
+
import { runSeedMigrationBatch, SeedMigrationError } from './src/migrations/signingAuthoritySeeds';
|
|
6
|
+
import type { SeedMigrationResult } from './src/types/seed-migration';
|
|
7
|
+
|
|
8
|
+
const requestValidator = z
|
|
9
|
+
.object({
|
|
10
|
+
phase: z.enum(['dry-run', 'prepare', 'verify', 'purge']),
|
|
11
|
+
batchSize: z.number().int().min(1).max(100).optional(),
|
|
12
|
+
})
|
|
13
|
+
.strict();
|
|
14
|
+
|
|
15
|
+
/** IAM invocation only. Never accept seeds, database URLs, key ARNs, or arbitrary queries in events. */
|
|
16
|
+
export const handler = async (event: unknown, context: Context): Promise<SeedMigrationResult> => {
|
|
17
|
+
const parsed = requestValidator.safeParse(event);
|
|
18
|
+
if (!parsed.success) throw new SeedMigrationError('invalid_request');
|
|
19
|
+
context.callbackWaitsForEmptyEventLoop = false;
|
|
20
|
+
let result: SeedMigrationResult;
|
|
21
|
+
let cleanupFailed = false;
|
|
22
|
+
try {
|
|
23
|
+
await client.connect();
|
|
24
|
+
result = await runSeedMigrationBatch(mongodb, parsed.data, {
|
|
25
|
+
encryptedWritesEnabled: environment.SA_SEED_ENCRYPT_WRITES,
|
|
26
|
+
remainingTime: () => context.getRemainingTimeInMillis(),
|
|
27
|
+
});
|
|
28
|
+
} catch (error) {
|
|
29
|
+
// Mongo connection/lease errors can contain URLs or query values. Never let Lambda serialize them.
|
|
30
|
+
throw new SeedMigrationError(
|
|
31
|
+
error instanceof SeedMigrationError ? error.category : 'operation_failed'
|
|
32
|
+
);
|
|
33
|
+
} finally {
|
|
34
|
+
// This private worker releases its pool between batches, including failed connections.
|
|
35
|
+
// The module-level client reconnects when a warm invocation starts its next batch.
|
|
36
|
+
try {
|
|
37
|
+
await client.close();
|
|
38
|
+
} catch {
|
|
39
|
+
cleanupFailed = true;
|
|
40
|
+
console.error({
|
|
41
|
+
event: 'signing_authority_seed_migration_failure',
|
|
42
|
+
operation: 'migration_connection_cleanup',
|
|
43
|
+
category: 'operation_failed',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Reached only after a successful batch; never mask its original failure in finally.
|
|
48
|
+
if (cleanupFailed) throw new SeedMigrationError('operation_failed');
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Context } from 'aws-lambda';
|
|
2
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({ bootstrap: vi.fn(), getApp: vi.fn(), handler: vi.fn() }));
|
|
5
|
+
|
|
6
|
+
vi.mock('@learncard/service-config', () => ({ bootstrapLambda: mocks.bootstrap }));
|
|
7
|
+
vi.mock('./src/config/stageConfig', () => ({ base: {}, stages: { dev: {}, production: {} } }));
|
|
8
|
+
vi.mock('./seedMigrationApp', () => ({ handler: mocks.handler }));
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
mocks.bootstrap.mockReturnValue(mocks.getApp);
|
|
14
|
+
mocks.getApp.mockResolvedValue({ handler: mocks.handler });
|
|
15
|
+
mocks.handler.mockResolvedValue({ phase: 'prepare', done: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('bootstraps the migration app with checked-in stage config and the deploy stage', async () => {
|
|
19
|
+
vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', 'lca-api-production-seedMigration');
|
|
20
|
+
vi.stubEnv('LAMBDA_STAGE', 'production');
|
|
21
|
+
await import('./seedMigrationLambda');
|
|
22
|
+
expect(mocks.bootstrap).toHaveBeenCalledTimes(1);
|
|
23
|
+
expect(mocks.bootstrap).toHaveBeenCalledWith({
|
|
24
|
+
base: {},
|
|
25
|
+
stages: { dev: {}, production: {} },
|
|
26
|
+
stage: 'production',
|
|
27
|
+
importApp: expect.any(Function),
|
|
28
|
+
});
|
|
29
|
+
vi.unstubAllEnvs();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('resolves the app once per cold start and forwards every argument', async () => {
|
|
33
|
+
const { handler } = await import('./seedMigrationLambda');
|
|
34
|
+
const event = { phase: 'prepare' };
|
|
35
|
+
const context = {} as Context;
|
|
36
|
+
await expect(handler(event, context)).resolves.toEqual({ phase: 'prepare', done: true });
|
|
37
|
+
await handler(event, context);
|
|
38
|
+
expect(mocks.getApp).toHaveBeenCalledTimes(2);
|
|
39
|
+
expect(mocks.handler).toHaveBeenLastCalledWith(event, context);
|
|
40
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Context } from 'aws-lambda';
|
|
2
|
+
import { bootstrapLambda } from '@learncard/service-config';
|
|
3
|
+
|
|
4
|
+
import { base, stages } from './src/config/stageConfig';
|
|
5
|
+
import type { SeedMigrationResult } from './src/types/seed-migration';
|
|
6
|
+
|
|
7
|
+
type SeedMigrationApp = typeof import('./seedMigrationApp');
|
|
8
|
+
|
|
9
|
+
const getApplication = bootstrapLambda<SeedMigrationApp>({
|
|
10
|
+
base,
|
|
11
|
+
stages,
|
|
12
|
+
stage: process.env.AWS_LAMBDA_FUNCTION_NAME
|
|
13
|
+
? process.env.LAMBDA_STAGE
|
|
14
|
+
: process.env.CONFIG_STAGE,
|
|
15
|
+
importApp: () => import('./seedMigrationApp'),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const handler = async (event: unknown, context: Context): Promise<SeedMigrationResult> =>
|
|
19
|
+
(await getApplication()).handler(event, context);
|