diff --git a/.changeset/lazy-elephants-suffer.md b/.changeset/lazy-elephants-suffer.md new file mode 100644 index 00000000000..0343b48f493 --- /dev/null +++ b/.changeset/lazy-elephants-suffer.md @@ -0,0 +1,5 @@ +--- +'@firebase/rules-unit-testing': minor +--- + +Add withFunctionTriggersDisabled function which runs a user-provided setup function with emulated Cloud Functions triggers disabled. This can be used to import data into the Realtime Database or Cloud Firestore emulators without triggering locally emulated Cloud Functions. This method only works with Firebase CLI version 8.13.0 or higher. diff --git a/packages/rules-unit-testing/firebase.json b/packages/rules-unit-testing/firebase.json index a5b6bcc199c..1b237fe5922 100644 --- a/packages/rules-unit-testing/firebase.json +++ b/packages/rules-unit-testing/firebase.json @@ -1,4 +1,7 @@ { + "functions": { + "source": "." + }, "emulators": { "firestore": { "port": 9003 @@ -6,6 +9,9 @@ "database": { "port": 9002 }, + "functions": { + "port": 9004 + }, "ui": { "enabled": false } diff --git a/packages/rules-unit-testing/index.ts b/packages/rules-unit-testing/index.ts index 953477f842d..3bb2c596a6b 100644 --- a/packages/rules-unit-testing/index.ts +++ b/packages/rules-unit-testing/index.ts @@ -31,5 +31,6 @@ export { initializeAdminApp, initializeTestApp, loadDatabaseRules, - loadFirestoreRules + loadFirestoreRules, + withFunctionTriggersDisabled } from './src/api'; diff --git a/packages/rules-unit-testing/package.json b/packages/rules-unit-testing/package.json index 9bb90657fb1..fcf48018816 100644 --- a/packages/rules-unit-testing/package.json +++ b/packages/rules-unit-testing/package.json @@ -13,7 +13,7 @@ "build:deps": "lerna run --scope @firebase/rules-unit-testing --include-dependencies build", "dev": "rollup -c -w", "test:nyc": "TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --config ../../config/mocharc.node.js", - "test": "firebase --debug emulators:exec 'yarn test:nyc'", + "test": "firebase --project=foo --debug emulators:exec 'yarn test:nyc'", "test:ci": "node ../../scripts/run_tests_in_ci.js -s test", "prepare": "yarn build" }, @@ -28,7 +28,8 @@ "@google-cloud/firestore": "4.4.0", "@types/request": "2.48.5", "firebase-admin": "9.2.0", - "firebase-tools": "8.12.1", + "firebase-tools": "8.13.0", + "firebase-functions": "3.11.0", "rollup": "2.29.0", "rollup-plugin-typescript2": "0.27.3" }, diff --git a/packages/rules-unit-testing/src/api/index.ts b/packages/rules-unit-testing/src/api/index.ts index 75d07908de3..680b195b09f 100644 --- a/packages/rules-unit-testing/src/api/index.ts +++ b/packages/rules-unit-testing/src/api/index.ts @@ -36,6 +36,11 @@ const FIRESTORE_ADDRESS_ENV: string = 'FIRESTORE_EMULATOR_HOST'; /** The default address for the local Firestore emulator. */ const FIRESTORE_ADDRESS_DEFAULT: string = 'localhost:8080'; +/** Environment variable to locate the Emulator Hub */ +const HUB_HOST_ENV: string = 'FIREBASE_EMULATOR_HUB'; +/** The default address for the Emulator hub */ +const HUB_HOST_DEFAULT: string = 'localhost:4400'; + /** The actual address for the database emulator */ let _databaseHost: string | undefined = undefined; @@ -307,7 +312,7 @@ export type LoadDatabaseRulesOptions = { databaseName: string; rules: string; }; -export function loadDatabaseRules( +export async function loadDatabaseRules( options: LoadDatabaseRulesOptions ): Promise { if (!options.databaseName) { @@ -318,33 +323,25 @@ export function loadDatabaseRules( throw Error('must provide rules to loadDatabaseRules'); } - return new Promise((resolve, reject) => { - request.put( - { - uri: `http://${getDatabaseHost()}/.settings/rules.json?ns=${ - options.databaseName - }`, - headers: { Authorization: 'Bearer owner' }, - body: options.rules - }, - (err, resp, body) => { - if (err) { - reject(err); - } else if (resp.statusCode !== 200) { - reject(JSON.parse(body).error); - } else { - resolve(); - } - } - ); + const resp = await requestPromise(request.put, { + method: 'PUT', + uri: `http://${getDatabaseHost()}/.settings/rules.json?ns=${ + options.databaseName + }`, + headers: { Authorization: 'Bearer owner' }, + body: options.rules }); + + if (resp.statusCode !== 200) { + throw new Error(JSON.parse(resp.body.error)); + } } export type LoadFirestoreRulesOptions = { projectId: string; rules: string; }; -export function loadFirestoreRules( +export async function loadFirestoreRules( options: LoadFirestoreRulesOptions ): Promise { if (!options.projectId) { @@ -355,64 +352,98 @@ export function loadFirestoreRules( throw new Error('must provide rules to loadFirestoreRules'); } - return new Promise((resolve, reject) => { - request.put( - { - uri: `http://${getFirestoreHost()}/emulator/v1/projects/${ - options.projectId - }:securityRules`, - body: JSON.stringify({ - rules: { - files: [{ content: options.rules }] - } - }) - }, - (err, resp, body) => { - if (err) { - reject(err); - } else if (resp.statusCode !== 200) { - console.log('body', body); - reject(JSON.parse(body).error); - } else { - resolve(); - } + const resp = await requestPromise(request.put, { + method: 'PUT', + uri: `http://${getFirestoreHost()}/emulator/v1/projects/${ + options.projectId + }:securityRules`, + body: JSON.stringify({ + rules: { + files: [{ content: options.rules }] } - ); + }) }); + + if (resp.statusCode !== 200) { + throw new Error(JSON.parse(resp.body.error)); + } } export type ClearFirestoreDataOptions = { projectId: string; }; -export function clearFirestoreData( +export async function clearFirestoreData( options: ClearFirestoreDataOptions ): Promise { if (!options.projectId) { throw new Error('projectId not specified'); } - return new Promise((resolve, reject) => { - request.delete( - { - uri: `http://${getFirestoreHost()}/emulator/v1/projects/${ - options.projectId - }/databases/(default)/documents`, - body: JSON.stringify({ - database: `projects/${options.projectId}/databases/(default)` - }) - }, - (err, resp, body) => { - if (err) { - reject(err); - } else if (resp.statusCode !== 200) { - console.log('body', body); - reject(JSON.parse(body).error); - } else { - resolve(); - } - } + const resp = await requestPromise(request.delete, { + method: 'DELETE', + uri: `http://${getFirestoreHost()}/emulator/v1/projects/${ + options.projectId + }/databases/(default)/documents`, + body: JSON.stringify({ + database: `projects/${options.projectId}/databases/(default)` + }) + }); + + if (resp.statusCode !== 200) { + throw new Error(JSON.parse(resp.body.error)); + } +} + +/** + * Run a setup function with background Cloud Functions triggers disabled. This can be used to + * import data into the Realtime Database or Cloud Firestore emulator without triggering locally + * emulated Cloud Functions. + * + * This method only works with Firebase CLI version 8.13.0 or higher. + * + * @param fn an function which returns a promise. + */ +export async function withFunctionTriggersDisabled( + fn: () => TResult | Promise +): Promise { + let hubHost = process.env[HUB_HOST_ENV]; + if (!hubHost) { + console.warn( + `${HUB_HOST_ENV} is not set, assuming the Emulator hub is running at ${HUB_HOST_DEFAULT}` ); + hubHost = HUB_HOST_DEFAULT; + } + + // Disable background triggers + const disableRes = await requestPromise(request.put, { + method: 'PUT', + uri: `http://${hubHost}/functions/disableBackgroundTriggers` }); + if (disableRes.statusCode !== 200) { + throw new Error( + `HTTP Error ${disableRes.statusCode} when disabling functions triggers, are you using firebase-tools 8.13.0 or higher?` + ); + } + + // Run the user's function + let result: TResult | undefined = undefined; + try { + result = await fn(); + } finally { + // Re-enable background triggers + const enableRes = await requestPromise(request.put, { + method: 'PUT', + uri: `http://${hubHost}/functions/enableBackgroundTriggers` + }); + if (enableRes.statusCode !== 200) { + throw new Error( + `HTTP Error ${enableRes.statusCode} when enabling functions triggers, are you using firebase-tools 8.13.0 or higher?` + ); + } + } + + // Return the user's function result + return result; } export function assertFails(pr: Promise): any { @@ -441,3 +472,22 @@ export function assertFails(pr: Promise): any { export function assertSucceeds(pr: Promise): any { return pr; } + +function requestPromise( + method: typeof request.get, + options: request.CoreOptions & request.UriOptions +): Promise<{ statusCode: number; body: any }> { + return new Promise((resolve, reject) => { + const callback: request.RequestCallback = (err, resp, body) => { + if (err) { + reject(err); + } else { + resolve({ statusCode: resp.statusCode, body }); + } + }; + + // Unfortunately request's default method is not very test-friendly so having + // the caler pass in the method here makes this whole thing compatible with sinon + method(options, callback); + }); +} diff --git a/packages/rules-unit-testing/test/database.test.ts b/packages/rules-unit-testing/test/database.test.ts index deb7b5c1863..d5929ee8666 100644 --- a/packages/rules-unit-testing/test/database.test.ts +++ b/packages/rules-unit-testing/test/database.test.ts @@ -17,6 +17,8 @@ import * as chai from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; +import * as request from 'request'; +import * as sinon from 'sinon'; import * as firebase from '../src/api'; import { base64 } from '@firebase/util'; import { _FirebaseApp } from '@firebase/app-types/private'; @@ -28,6 +30,15 @@ before(() => { }); describe('Testing Module Tests', function () { + let sandbox: sinon.SinonSandbox; + beforeEach(function () { + sandbox = sinon.createSandbox(); + }); + + afterEach(function () { + sandbox && sandbox.restore(); + }); + it('assertSucceeds() iff success', async function () { const success = Promise.resolve('success'); const failure = Promise.reject('failure'); @@ -262,19 +273,19 @@ describe('Testing Module Tests', function () { it('loadDatabaseRules() throws if no databaseName or rules', async function () { // eslint-disable-next-line @typescript-eslint/no-explicit-any - await expect((firebase as any).loadDatabaseRules.bind(null, {})).to.throw( - /databaseName not specified/ - ); + await expect( + firebase.loadDatabaseRules({} as any) + ).to.eventually.be.rejectedWith(/databaseName not specified/); // eslint-disable-next-line @typescript-eslint/no-explicit-any await expect( - (firebase as any).loadDatabaseRules.bind(null, { + firebase.loadDatabaseRules({ databaseName: 'foo' - }) as Promise - ).to.throw(/must provide rules/); + } as any) + ).to.eventually.be.rejectedWith(/must provide rules/); await expect( // eslint-disable-next-line @typescript-eslint/no-explicit-any - (firebase as any).loadDatabaseRules.bind(null, { rules: '{}' }) - ).to.throw(/databaseName not specified/); + firebase.loadDatabaseRules({ rules: '{}' } as any) + ).to.eventually.be.rejectedWith(/databaseName not specified/); }); it('loadDatabaseRules() succeeds on valid input', async function () { @@ -318,4 +329,26 @@ describe('Testing Module Tests', function () { it('there is a way to get firestore timestamps', function () { expect(firebase.firestore.FieldValue.serverTimestamp()).not.to.be.null; }); + + it('disabling function triggers does not throw, returns value', async function () { + const putSpy = sandbox.spy(request, 'put'); + + const res = await firebase.withFunctionTriggersDisabled(() => { + return Promise.resolve(1234); + }); + + expect(res).to.eq(1234); + expect(putSpy.callCount).to.equal(2); + }); + + it('disabling function triggers always re-enables, event when the function throws', async function () { + const putSpy = sandbox.spy(request, 'put'); + + const res = firebase.withFunctionTriggersDisabled(() => { + throw new Error('I throw!'); + }); + + await expect(res).to.eventually.be.rejectedWith('I throw!'); + expect(putSpy.callCount).to.equal(2); + }); }); diff --git a/yarn.lock b/yarn.lock index f6cb6b44204..9b8f90741b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7280,6 +7280,64 @@ firebase-tools@8.12.1: winston "^3.0.0" ws "^7.2.3" +firebase-tools@8.13.0: + version "8.13.0" + resolved "https://registry.npmjs.org/firebase-tools/-/firebase-tools-8.13.0.tgz#324eb47d9c3987b85dfa6818aebd077df0fc431b" + integrity sha512-IlGJA5WVDTrjj02anUhuBwaCHe+WtB0gNbp9SjIRqIVYbMpJWPi25sqyiJ5kb4u7r7lZOcSGQbAYHqpDdzakfQ== + dependencies: + "@google-cloud/pubsub" "^1.7.0" + JSONStream "^1.2.1" + archiver "^3.0.0" + body-parser "^1.19.0" + chokidar "^3.0.2" + cjson "^0.3.1" + cli-color "^1.2.0" + cli-table "^0.3.1" + commander "^4.0.1" + configstore "^5.0.1" + cross-env "^5.1.3" + cross-spawn "^7.0.1" + csv-streamify "^3.0.4" + dotenv "^6.1.0" + exegesis-express "^2.0.0" + exit-code "^1.0.2" + express "^4.16.4" + filesize "^3.1.3" + fs-extra "^0.23.1" + glob "^7.1.2" + google-auth-library "^5.5.0" + google-gax "~1.12.0" + inquirer "~6.3.1" + js-yaml "^3.13.1" + jsonschema "^1.0.2" + jsonwebtoken "^8.2.1" + leven "^3.1.0" + lodash "^4.17.19" + marked "^0.7.0" + marked-terminal "^3.3.0" + minimatch "^3.0.4" + morgan "^1.10.0" + open "^6.3.0" + ora "^3.4.0" + plist "^3.0.1" + portfinder "^1.0.23" + progress "^2.0.3" + request "^2.87.0" + rimraf "^3.0.0" + semver "^5.7.1" + superstatic "^7.0.0" + tar "^4.3.0" + tcp-port-used "^1.0.1" + tmp "0.0.33" + triple-beam "^1.3.0" + tweetsodium "0.0.5" + universal-analytics "^0.4.16" + unzipper "^0.10.10" + update-notifier "^4.1.0" + uuid "^3.0.0" + winston "^3.0.0" + ws "^7.2.3" + flagged-respawn@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz#e7de6f1279ddd9ca9aac8a5971d618606b3aab41"