diff --git a/firebasestorage.rules b/firebasestorage.rules index ddebe61b..be663e89 100644 --- a/firebasestorage.rules +++ b/firebasestorage.rules @@ -2,7 +2,7 @@ rules_version = '2'; service firebase.storage { match /b/{bucket}/o { match /users/{userId}/consent/{fileName} { - allow read, write: if request.auth != null && request.auth.uid == userId + allow read, write: if request.auth != null && request.auth.uid == userId && ('type' in request.auth.token); } } } \ No newline at end of file diff --git a/firestore.rules b/firestore.rules index ca5a7e12..b6453fee 100644 --- a/firestore.rules +++ b/firestore.rules @@ -2,7 +2,7 @@ rules_version = '2'; service cloud.firestore { match /databases/{databaseId}/documents { function isAuthenticated() { - return request.auth != null; + return request.auth != null && ('type' in request.auth.token); } function isAdmin() { @@ -115,7 +115,7 @@ service cloud.firestore { } allow read: if isAdmin() - || isUser(userId) + || (request.auth != null && request.auth.uid == userId) || isOwnerOrClinicianOf(resource.data.organization); allow create: if isAdmin() diff --git a/functions/data/debug/users.json b/functions/data/debug/users.json index 066a0b69..b78b69d7 100644 --- a/functions/data/debug/users.json +++ b/functions/data/debug/users.json @@ -10,7 +10,7 @@ "type": "admin", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "engagehf-admin0@stanford.edu" } }, { @@ -24,7 +24,7 @@ "type": "admin", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "engagehf-admin1@stanford.edu" } }, { @@ -39,7 +39,7 @@ "organization": "stanford", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "engagehf-owner0@stanford.edu" } }, { @@ -54,7 +54,7 @@ "organization": "stanford", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "engagehf-owner1@stanford.edu" } }, { @@ -69,7 +69,7 @@ "organization": "jhu", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "engagehf-owner0@jhu.edu" } }, { @@ -84,7 +84,7 @@ "organization": "umich", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "engagehf-owner0@umich.edu" } }, { @@ -99,7 +99,7 @@ "organization": "uw", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "engagehf-admin0@uw.edu" } }, { @@ -114,7 +114,7 @@ "organization": "stanford", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "engagehf-clinician0@stanford.edu" } }, { @@ -129,14 +129,14 @@ "organization": "stanford", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "engagehf-clinician1@stanford.edu" } }, { "auth": { - "uid": "engagehf-clinician-jhu.edu", + "uid": "engagehf-clinician0-jhu.edu", "displayName": "JHU Clinician", - "email": "engagehf-clinician@jhu.edu", + "email": "engagehf-clinician0@jhu.edu", "password": "password" }, "user": { @@ -144,14 +144,14 @@ "organization": "jhu", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "engagehf-clinician0@jhu.edu" } }, { "auth": { - "uid": "engagehf-clinician-umich.edu", + "uid": "engagehf-clinician0-umich.edu", "displayName": "UMich Clinician", - "email": "engagehf-clinician@umich.edu", + "email": "engagehf-clinician0@umich.edu", "password": "password" }, "user": { @@ -159,14 +159,14 @@ "organization": "umich", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "engagehf-clinician0@umich.edu" } }, { "auth": { - "uid": "engagehf-clinician-uw.edu", + "uid": "engagehf-clinician0-uw.edu", "displayName": "UW Clinician", - "email": "engagehf-clinician@uw.edu", + "email": "engagehf-clinician0@uw.edu", "password": "password" }, "user": { @@ -174,7 +174,7 @@ "organization": "uw", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "engagehf-clinician0@uw.edu" } }, { @@ -189,7 +189,7 @@ "organization": "stanford", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "SEEDING0" } }, { @@ -204,7 +204,7 @@ "organization": "stanford", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "SEEDING1" } }, { @@ -219,7 +219,7 @@ "organization": "stanford", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "SEEDING2" } }, { @@ -234,7 +234,7 @@ "organization": "jhu", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "SEEDING3" } }, { @@ -249,7 +249,7 @@ "organization": "jhu", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "SEEDING4" } }, { @@ -264,7 +264,7 @@ "organization": "jhu", "dateOfEnrollment": "1970-01-01T00:00:00.000Z", "lastActiveDate": "1970-01-01T00:00:00.000Z", - "invitationCode": "" + "invitationCode": "SEEDING5" } } ] diff --git a/functions/models/package-lock.json b/functions/models/package-lock.json index e60cdb2a..c46f8302 100644 --- a/functions/models/package-lock.json +++ b/functions/models/package-lock.json @@ -41,9 +41,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", + "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", "dev": true, "license": "MIT", "engines": { diff --git a/functions/models/src/functions/checkInvitationCode.ts b/functions/models/src/functions/enrollUser.ts similarity index 50% rename from functions/models/src/functions/checkInvitationCode.ts rename to functions/models/src/functions/enrollUser.ts index d38931d1..b65635e2 100644 --- a/functions/models/src/functions/checkInvitationCode.ts +++ b/functions/models/src/functions/enrollUser.ts @@ -8,11 +8,9 @@ import { z } from 'zod' -export const checkInvitationCodeInputSchema = z.object({ - invitationCode: z.string(), +export const enrollUserInputSchema = z.object({ + invitationCode: z.string().regex(/^[A-Z0-9]{8,16}$/), }) -export type CheckInvitationCodeInput = z.input< - typeof checkInvitationCodeInputSchema -> +export type EnrollUserInputSchema = z.input -export type CheckInvitationCodeOutput = undefined +export type EnrollUserOutputSchema = undefined diff --git a/functions/models/src/index.ts b/functions/models/src/index.ts index 658d78fe..f5c56498 100644 --- a/functions/models/src/index.ts +++ b/functions/models/src/index.ts @@ -26,13 +26,13 @@ export * from './fhir/fhirMedication.js' export * from './fhir/fhirObservation.js' export * from './fhir/fhirQuestionnaire.js' export * from './fhir/fhirQuestionnaireResponse.js' -export * from './functions/checkInvitationCode.js' export * from './functions/createInvitation.js' export * from './functions/customSeed.js' export * from './functions/defaultSeed.js' export * from './functions/deleteInvitation.js' export * from './functions/deleteUser.js' export * from './functions/dismissMessage.js' +export * from './functions/enrollUser.js' export * from './functions/exportHealthSummary.js' export * from './functions/getUsersInformation.js' export * from './functions/registerDevice.js' diff --git a/functions/models/src/types/invitation.ts b/functions/models/src/types/invitation.ts index 39098f36..c62c7f32 100644 --- a/functions/models/src/types/invitation.ts +++ b/functions/models/src/types/invitation.ts @@ -21,14 +21,12 @@ export const invitationConverter = new Lazy( new SchemaConverter({ schema: z .object({ - userId: optionalish(z.string()), code: z.string(), auth: optionalish(z.lazy(() => userAuthConverter.value.schema)), user: z.lazy(() => userRegistrationConverter.value.schema), }) .transform((values) => new Invitation(values)), encode: (object) => ({ - userId: object.userId ?? null, code: object.code, auth: object.auth ? userAuthConverter.value.encode(object.auth) : null, user: userRegistrationConverter.value.encode(object.user), @@ -39,7 +37,6 @@ export const invitationConverter = new Lazy( export class Invitation { // Properties - readonly userId?: string readonly code: string readonly auth?: UserAuth readonly user: UserRegistration @@ -47,12 +44,10 @@ export class Invitation { // Constructor constructor(input: { - userId?: string code: string auth?: UserAuth user: UserRegistration }) { - this.userId = input.userId this.code = input.code this.auth = input.auth this.user = input.user diff --git a/functions/package-lock.json b/functions/package-lock.json index 142fbe55..a04bce21 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -777,9 +777,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", + "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", "dev": true, "license": "MIT", "engines": { @@ -2654,25 +2654,25 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", - "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^2.0.0", + "@sinonjs/commons": "^3.0.1", "lodash.get": "^4.4.2", - "type-detect": "^4.0.8" + "type-detect": "^4.1.0" } }, - "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", - "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" + "license": "MIT", + "engines": { + "node": ">=4" } }, "node_modules/@sinonjs/text-encoding": { @@ -3196,9 +3196,9 @@ "license": "MIT" }, "node_modules/@types/mocha": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.7.tgz", - "integrity": "sha512-GN8yJ1mNTcFcah/wKEFIJckJx9iJLoMSzWcfRRuxz/Jk+U6KQNnml+etbtxFK8lPjzOw3zp4Ha/kjSst9fsHYw==", + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.8.tgz", + "integrity": "sha512-HfMcUmy9hTMJh66VNcmeC9iVErIZJli2bszuXc6julh5YGuRb/W5OnkHjwLNYdFlMis0sY3If5SEAp+PktdJjw==", "dev": true, "license": "MIT" }, @@ -3223,9 +3223,9 @@ } }, "node_modules/@types/qs": { - "version": "6.9.15", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", - "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==", + "version": "6.9.16", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", + "integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==", "license": "MIT" }, "node_modules/@types/raf": { @@ -4194,21 +4194,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -5492,9 +5477,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.19", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.19.tgz", - "integrity": "sha512-kpLJJi3zxTR1U828P+LIUDZ5ohixyo68/IcYOHLqnbTPr/wdgn4i1ECvmALN9E16JPA6cvCG5UG79gVwVdEK5w==", + "version": "1.5.23", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.23.tgz", + "integrity": "sha512-mBhODedOXg4v5QWwl21DjM5amzjmI1zw9EPrPK/5Wx7C8jt33bpZNrC7OhHUG3pxRtbLpr3W2dXT+Ph1SsfRZA==", "dev": true, "license": "ISC", "peer": true @@ -6196,9 +6181,9 @@ } }, "node_modules/express": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.20.0.tgz", - "integrity": "sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -6213,7 +6198,7 @@ "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", @@ -6222,11 +6207,11 @@ "parseurl": "~1.3.3", "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", - "serve-static": "1.16.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -6415,13 +6400,13 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -6441,15 +6426,6 @@ "ms": "2.0.0" } }, - "node_modules/finalhandler/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -6511,9 +6487,9 @@ } }, "node_modules/firebase-admin": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-12.4.0.tgz", - "integrity": "sha512-3HOHqJxNmFv0JgK3voyMQgmcibhJN4LQfZfhnZGb6pcONnZxejki4nQ1twsoJlGaIvgQWBtO7rc5mh/cqlOJNA==", + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-12.5.0.tgz", + "integrity": "sha512-ad8vnlPcuuZN9scSgY8UnAxPI4mzP2/Q+dsrVLTf+j3h7bIq0FOelDCDGz4StgKJdk244v2kpOxqJjPG3grBHg==", "license": "Apache-2.0", "dependencies": { "@fastify/busboy": "^3.0.0", @@ -6535,9 +6511,9 @@ } }, "node_modules/firebase-admin/node_modules/@types/node": { - "version": "22.5.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", - "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "version": "22.5.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz", + "integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==", "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -9422,23 +9398,23 @@ } }, "node_modules/nise": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/nise/-/nise-6.0.1.tgz", - "integrity": "sha512-DAyWGPQEuJVlL2eqKw6gdZKT+E/jo/ZrjEUDAslJLluCz81nWy+KSYybNp3KFm887Yvp7hv12jSM82ld8BmLxg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.0", - "@sinonjs/fake-timers": "^11.2.2", - "@sinonjs/text-encoding": "^0.7.2", + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", "just-extend": "^6.2.0", "path-to-regexp": "^8.1.0" } }, "node_modules/nise/node_modules/@sinonjs/fake-timers": { - "version": "11.3.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", - "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.2.tgz", + "integrity": "sha512-4Bb+oqXZTSTZ1q27Izly9lv8B9dlV61CROxPiVtywwzv5SnytJqhvYe6FclHYuXml4cd1VHPo1zd5PmTeJozvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -10207,12 +10183,12 @@ "peer": true }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -10708,75 +10684,15 @@ } }, "node_modules/serve-static": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.0.tgz", - "integrity": "sha512-pDLK8zwl2eKaYrs8mrPZBJua4hMplRWJ1tIFksVC3FtBEBnl8dxgeHtsaMS8DhS9i4fLObaon6ABoc4/hQGdPA==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "license": "MIT", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-static/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-static/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/serve-static/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/serve-static/node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -10876,14 +10792,14 @@ } }, "node_modules/sinon": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.0.tgz", - "integrity": "sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==", + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.1.tgz", + "integrity": "sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/fake-timers": "11.2.2", "@sinonjs/samsam": "^8.0.0", "diff": "^5.2.0", "nise": "^6.0.0", @@ -10895,13 +10811,13 @@ } }, "node_modules/sinon/node_modules/@sinonjs/fake-timers": { - "version": "11.3.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", - "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.1" + "@sinonjs/commons": "^3.0.0" } }, "node_modules/sisteransi": { diff --git a/functions/src/functions/blocking.ts b/functions/src/functions/blocking.ts index 472ecadf..1a194aa8 100644 --- a/functions/src/functions/blocking.ts +++ b/functions/src/functions/blocking.ts @@ -6,15 +6,56 @@ // SPDX-License-Identifier: MIT // -import { logger } from 'firebase-functions' +import { UserType } from '@stanfordbdhg/engagehf-models' +import { https, logger } from 'firebase-functions' import { beforeUserCreated, beforeUserSignedIn, } from 'firebase-functions/v2/identity' import { getServiceFactory } from '../services/factory/getServiceFactory.js' -export const beforeUserCreatedFunction = beforeUserCreated((event) => { - logger.info(`beforeUserCreated with event: ${JSON.stringify(event)}`) +export const beforeUserCreatedFunction = beforeUserCreated(async (event) => { + const userId = event.data.uid + + const factory = getServiceFactory() + const userService = factory.user() + const credential = event.credential + + // Escape hatch for users using invitation code to enroll + if (!credential) return + + if (event.data.email === undefined) + throw new https.HttpsError( + 'invalid-argument', + 'Email address is required for user.', + ) + + const organization = await userService.getOrganizationBySsoProviderId( + credential.providerId, + ) + + if (organization === undefined) + throw new https.HttpsError('failed-precondition', 'Organization not found.') + + const invitation = await userService.getInvitationByCode(event.data.email) + if (invitation?.content === undefined) { + throw new https.HttpsError( + 'not-found', + 'No valid invitation code found for user.', + ) + } + + if ( + invitation.content.user.type === UserType.admin && + invitation.content.user.organization !== organization.id + ) + throw new https.HttpsError( + 'failed-precondition', + 'Organization does not match invitation code.', + ) + + await userService.enrollUser(invitation, userId) + await factory.trigger().userEnrolled(userId) }) export const beforeUserSignedInFunction = beforeUserSignedIn(async (event) => { diff --git a/functions/src/functions/checkInvitationCode.ts b/functions/src/functions/checkInvitationCode.ts deleted file mode 100644 index 1f93b024..00000000 --- a/functions/src/functions/checkInvitationCode.ts +++ /dev/null @@ -1,70 +0,0 @@ -// -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// - -// Based on: -// https://github.com/StanfordBDHG/PediatricAppleWatchStudy/pull/54/files - -import { - checkInvitationCodeInputSchema, - type CheckInvitationCodeOutput, -} from '@stanfordbdhg/engagehf-models' -import { https, logger } from 'firebase-functions/v2' -import { validatedOnCall } from './helpers.js' -import { getServiceFactory } from '../services/factory/getServiceFactory.js' - -export const checkInvitationCode = validatedOnCall( - 'checkInvitationCode', - checkInvitationCodeInputSchema, - async (request): Promise => { - const factory = getServiceFactory() - const userId = factory.credential(request.auth).userId - - if (!request.data.invitationCode.match(/^[A-Z0-9]{6,12}$/)) - throw new https.HttpsError( - 'invalid-argument', - 'Invalid invitation code format.', - ) - - const { invitationCode } = request.data - - logger.debug( - `User (${userId}) -> ENGAGE-HF, InvitationCode ${invitationCode}`, - ) - - const userService = factory.user() - - try { - await userService.setInvitationUserId(invitationCode, userId) - - logger.debug( - `User (${userId}) successfully enrolled in study (ENGAGE-HF) with invitation code: ${invitationCode}`, - ) - - const invitation = await userService.getInvitationByUserId(userId) - - if (!invitation) - throw new https.HttpsError( - 'not-found', - 'Invitation not found for user.', - ) - - await userService.enrollUser(invitation, userId) - await factory.trigger().userEnrolled(userId) - } catch (error) { - if (error instanceof Error) { - logger.error(`Error processing request: ${error.message}`) - if ('message' in error) { - throw new https.HttpsError('internal', error.message) - } - } else { - logger.error(`Unknown error: ${String(error)}`) - } - throw error - } - }, -) diff --git a/functions/src/functions/checkInvitationCode.test.ts b/functions/src/functions/enrollUser.test.ts similarity index 90% rename from functions/src/functions/checkInvitationCode.test.ts rename to functions/src/functions/enrollUser.test.ts index 2fdbbb8e..b5c247b3 100644 --- a/functions/src/functions/checkInvitationCode.test.ts +++ b/functions/src/functions/enrollUser.test.ts @@ -21,26 +21,26 @@ import { UserType, } from '@stanfordbdhg/engagehf-models' import { expect } from 'chai' -import { checkInvitationCode } from './checkInvitationCode.js' +import { enrollUser } from './enrollUser.js' import { UserObservationCollection } from '../services/database/collections.js' import { describeWithEmulators } from '../tests/functions/testEnvironment.js' import { expectError } from '../tests/helpers.js' -describeWithEmulators('function: checkInvitationCode', (env) => { - it('should fail if the invitation code does not exist', async () => { +describeWithEmulators('function: enrollUser', (env) => { + it('fails to enroll a user without an invitation code', async () => { + const authUser = await env.auth.createUser({}) await expectError( async () => env.call( - checkInvitationCode, + enrollUser, { invitationCode: 'TESTCODE' }, - { uid: 'test' }, + { uid: authUser.uid }, ), - (error) => - expect(error).to.have.property('message', 'Invitation not found'), + (error) => expect(error).to.have.property('code', 'not-found'), ) }) - it('should succeed if invitation code exists', async () => { + it('correctly enrolls a user', async () => { const invitation = new Invitation({ auth: new UserAuth({ email: 'engagehf-test@stanford.edu', @@ -92,7 +92,7 @@ describeWithEmulators('function: checkInvitationCode', (env) => { const authUser = await env.auth.createUser({}) await env.call( - checkInvitationCode, + enrollUser, { invitationCode: 'TESTCODE' }, { uid: authUser.uid }, ) diff --git a/functions/src/functions/enrollUser.ts b/functions/src/functions/enrollUser.ts new file mode 100644 index 00000000..3904b0a7 --- /dev/null +++ b/functions/src/functions/enrollUser.ts @@ -0,0 +1,40 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import { enrollUserInputSchema } from '@stanfordbdhg/engagehf-models' +import { https, logger } from 'firebase-functions' +import { validatedOnCall } from './helpers.js' +import { getServiceFactory } from '../services/factory/getServiceFactory.js' + +export const enrollUser = validatedOnCall( + 'enrollUser', + enrollUserInputSchema, + async (request) => { + const factory = getServiceFactory() + const credential = factory.credential(request.auth) + const triggerService = factory.trigger() + const userService = factory.user() + + const userId = credential.userId + const invitationCode = request.data.invitationCode + + const invitation = await userService.getInvitationByCode(invitationCode) + if (invitation === undefined) + throw new https.HttpsError('not-found', 'Invitation not found') + + await userService.enrollUser(invitation, userId) + + logger.debug( + `setupUser: User '${userId}' successfully enrolled in the study with invitation code: ${invitationCode}`, + ) + + await triggerService.userEnrolled(userId) + + logger.debug(`setupUser: User '${userId}' enrollment triggers finished`) + }, +) diff --git a/functions/src/index.ts b/functions/src/index.ts index b372d09a..59f1c50d 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -14,12 +14,12 @@ export { beforeUserCreatedFunction as beforeUserCreated, beforeUserSignedInFunction as beforeUserSignedIn, } from './functions/blocking.js' -export * from './functions/checkInvitationCode.js' export * from './functions/createInvitation.js' export * from './functions/customSeed.js' export * from './functions/defaultSeed.js' export * from './functions/deleteUser.js' export * from './functions/dismissMessage.js' +export * from './functions/enrollUser.js' export * from './functions/exportHealthSummary.js' export * from './functions/getUsersInformation.js' export * from './functions/onHistoryWritten.js' diff --git a/functions/src/services/trigger/triggerService.ts b/functions/src/services/trigger/triggerService.ts index db5fab53..227cf52e 100644 --- a/functions/src/services/trigger/triggerService.ts +++ b/functions/src/services/trigger/triggerService.ts @@ -581,6 +581,14 @@ export class TriggerService { // Helpers + private async deleteExpiredAccounts() { + try { + await this.factory.user().deleteExpiredAccounts() + } catch (error) { + console.error(`Error clearing expired accounts: ${String(error)}`) + } + } + private async getRecommendationVitals( patientService: PatientService, userId: string, diff --git a/functions/src/services/user/databaseUserService.test.ts b/functions/src/services/user/databaseUserService.test.ts index b7fc6760..75f42969 100644 --- a/functions/src/services/user/databaseUserService.test.ts +++ b/functions/src/services/user/databaseUserService.test.ts @@ -41,13 +41,9 @@ describe('DatabaseUserService', () => { invitations: { invitationId: { code: invitationCode, + userId, user: { type: UserType.admin, - messagesSettings: { - dailyRemindersAreActive: true, - textNotificationsAreActive: true, - medicationRemindersAreActive: true, - }, }, auth: { displayName: displayName, @@ -83,13 +79,9 @@ describe('DatabaseUserService', () => { invitations: { invitationId: { code: invitationCode, + userId, user: { type: UserType.clinician, - messagesSettings: { - dailyRemindersAreActive: true, - textNotificationsAreActive: true, - medicationRemindersAreActive: true, - }, organization: 'mockOrganization', }, auth: { @@ -130,15 +122,11 @@ describe('DatabaseUserService', () => { invitations: { invitationId: { code: invitationCode, + userId, user: { type: UserType.patient, clinician: 'mockClinician', dateOfBirth: new Date().toISOString(), - messagesSettings: { - dailyRemindersAreActive: true, - textNotificationsAreActive: true, - medicationRemindersAreActive: true, - }, organization: 'mockOrganization', }, auth: { diff --git a/functions/src/services/user/databaseUserService.ts b/functions/src/services/user/databaseUserService.ts index e66def6e..53c2ecb2 100644 --- a/functions/src/services/user/databaseUserService.ts +++ b/functions/src/services/user/databaseUserService.ts @@ -7,6 +7,7 @@ // import { + advanceDateByDays, dateConverter, type Invitation, type Organization, @@ -64,19 +65,23 @@ export class DatabaseUserService implements UserService { async updateClaims(userId: string): Promise { try { const user = await this.getUser(userId) - if (!user) throw new https.HttpsError('not-found', 'User not found.') - - const claims: UserClaims = { - type: user.content.type, + if (user !== undefined) { + const claims: UserClaims = { + type: user.content.type, + } + if (user.content.organization !== undefined) + claims.organization = user.content.organization + logger.info( + `Will set claims for user ${userId}: ${JSON.stringify(claims)}`, + ) + await this.auth.setCustomUserClaims(userId, claims) + logger.info(`Successfully set claims for user ${userId}.`) + } else { + await this.auth.setCustomUserClaims(userId, {}) + logger.info( + `Successfully set claims for not-yet-enrolled user ${userId}.`, + ) } - if (user.content.organization !== undefined) - claims.organization = user.content.organization - - logger.info( - `Will set claims for user ${userId}: ${JSON.stringify(claims)}`, - ) - await this.auth.setCustomUserClaims(userId, claims) - logger.info(`Successfully set claims for user ${userId}.`) } catch (error) { logger.error( `Failed to update claims for user ${userId}: ${String(error)}`, @@ -127,40 +132,6 @@ export class DatabaseUserService implements UserService { return result.at(0) } - async setInvitationUserId( - invitationCode: string, - userId: string, - ): Promise { - await this.databaseService.runTransaction( - async (collections, transaction) => { - const invitation = ( - await collections.invitations - .where('code', '==', invitationCode) - .limit(1) - .get() - ).docs.at(0) - if (invitation === undefined) { - logger.error(`Invitation with code '${invitationCode}' not found.`) - throw new Error('Invitation not found') - } - logger.info( - `Setting userId '${userId}' for invitation with code '${invitationCode}' at id '${invitation.id}'.`, - ) - transaction.update(invitation.ref, { userId: userId }) - }, - ) - } - - async getInvitationByUserId( - userId: string, - ): Promise | undefined> { - const result = await this.databaseService.getQuery( - (collections) => - collections.invitations.where('userId', '==', userId).limit(1), - ) - return result.at(0) - } - async enrollUser( invitation: Document, userId: string, @@ -236,6 +207,13 @@ export class DatabaseUserService implements UserService { ), ) + await this.databaseService.bulkWrite(async (collections, writer) => { + await collections.firestore.recursiveDelete( + collections.invitations.doc(invitation.id), + writer, + ) + }) + await this.updateClaims(userId) } @@ -309,4 +287,33 @@ export class DatabaseUserService implements UserService { logger.info(`Deleted user auth with id '${userId}'.`) }) } + + async deleteExpiredAccounts(): Promise { + const oneDayAgo = advanceDateByDays(new Date(), -1) + const promises: Array> = [] + let pageToken: string | undefined = undefined + do { + const usersResult = await this.auth.listUsers(1_000, pageToken) + pageToken = usersResult.pageToken + for (const user of usersResult.users) { + if ( + Object.keys(user.customClaims ?? {}).length === 0 && + new Date(user.metadata.lastSignInTime) < oneDayAgo + ) { + logger.info(`Deleting expired account ${user.uid}`) + promises.push( + this.auth + .deleteUser(user.uid) + .catch((error: unknown) => + console.error( + `Failed to delete expired account ${user.uid}: ${String(error)}`, + ), + ), + ) + } + } + } while (pageToken !== undefined) + + await Promise.all(promises) + } } diff --git a/functions/src/services/user/userService.mock.ts b/functions/src/services/user/userService.mock.ts index 1be9a72d..5da6cd72 100644 --- a/functions/src/services/user/userService.mock.ts +++ b/functions/src/services/user/userService.mock.ts @@ -76,45 +76,10 @@ export class MockUserService implements UserService { timeZone: 'America/Los_Angeles', }), code: invitationCode, - userId: 'test', }), } } - async getInvitationByUserId( - userId: string, - ): Promise | undefined> { - return { - id: '123', - path: 'invitations/123', - content: new Invitation({ - user: new UserRegistration({ - type: UserType.patient, - dateOfBirth: new Date('1970-01-02'), - clinician: 'mockPatient', - receivesAppointmentReminders: true, - receivesInactivityReminders: true, - receivesMedicationUpdates: true, - receivesQuestionnaireReminders: true, - receivesRecommendationUpdates: true, - receivesVitalsReminders: true, - receivesWeightAlerts: true, - organization: 'stanford', - timeZone: 'America/Los_Angeles', - }), - code: 'test', - userId: userId, - }), - } - } - - async setInvitationUserId( - invitationCode: string, - userId: string, - ): Promise { - return - } - async enrollUser( invitation: Document, userId: string, @@ -191,4 +156,8 @@ export class MockUserService implements UserService { async deleteUser(userId: string): Promise { return } + + async deleteExpiredAccounts(): Promise { + return + } } diff --git a/functions/src/services/user/userService.ts b/functions/src/services/user/userService.ts index 57f674ba..7421e13e 100644 --- a/functions/src/services/user/userService.ts +++ b/functions/src/services/user/userService.ts @@ -32,10 +32,6 @@ export interface UserService { getInvitationByCode( invitationCode: string, ): Promise | undefined> - setInvitationUserId(invitationCode: string, userId: string): Promise - getInvitationByUserId( - userId: string, - ): Promise | undefined> enrollUser(invitation: Document, userId: string): Promise deleteInvitation(invitation: Document): Promise @@ -55,4 +51,5 @@ export interface UserService { getUser(userId: string): Promise | undefined> updateLastActiveDate(userId: string): Promise deleteUser(userId: string): Promise + deleteExpiredAccounts(): Promise } diff --git a/functions/src/tests/functions/testEnvironment.ts b/functions/src/tests/functions/testEnvironment.ts index bd34a050..53459750 100644 --- a/functions/src/tests/functions/testEnvironment.ts +++ b/functions/src/tests/functions/testEnvironment.ts @@ -20,7 +20,6 @@ import firebaseFunctionsTest from 'firebase-functions-test' import { CollectionsService } from '../../services/database/collections.js' import { getServiceFactory } from '../../services/factory/getServiceFactory.js' import { type ServiceFactory } from '../../services/factory/serviceFactory.js' -import { type UserClaims } from '../../services/user/databaseUserService.js' import { TestFlags } from '../testFlags.js' export function describeWithEmulators( @@ -81,7 +80,7 @@ export class EmulatorTestEnvironment { async call( func: CallableFunction, input: Input, - auth: { uid: string; token?: Partial }, + auth: { uid: string; token?: object }, ): Promise { const wrapped = this.wrapper.wrap(func) return wrapped({ diff --git a/functions/src/tests/mocks/firestore.ts b/functions/src/tests/mocks/firestore.ts index 2e13c575..85af9d5e 100644 --- a/functions/src/tests/mocks/firestore.ts +++ b/functions/src/tests/mocks/firestore.ts @@ -49,6 +49,31 @@ export class MockFirestore { ) { return callback(new MockFirestoreTransaction()) } + + bulkWriter(): MockFirestoreBulkWriter { + return new MockFirestoreBulkWriter() + } + + recursiveDelete(reference: MockFirestoreRef) { + if (reference instanceof MockFirestoreCollectionRef) { + this.collections.delete(reference.path) + } else if (reference instanceof MockFirestoreDocRef) { + reference.delete() + } else { + throw new Error('Unsupported reference type') + } + this.collections.forEach((_, key) => { + if (key.startsWith(reference.path + '/')) { + this.collections.delete(key) + } + }) + } +} + +class MockFirestoreBulkWriter { + close() { + return + } } class MockFirestoreTransaction { diff --git a/functions/src/tests/rules/users.test.ts b/functions/src/tests/rules/users.test.ts index 9e6d0d46..407eda99 100644 --- a/functions/src/tests/rules/users.test.ts +++ b/functions/src/tests/rules/users.test.ts @@ -199,7 +199,7 @@ describe('firestore.rules: users/{userId}', () => { await testEnvironment.withSecurityRulesDisabled(async (environment) => { await environment.firestore().doc(`users/${userId}`).delete() }) - await assertSucceeds(userFirestore.doc(`users/${userId}`).set({})) + await assertFails(userFirestore.doc(`users/${userId}`).set({})) }) it('updates users/{userId} as admin', async () => {