Skip to content

Add withFunctionsTriggersDisabled method to rules-unit-testing #3928

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Oct 30, 2020
5 changes: 5 additions & 0 deletions .changeset/lazy-elephants-suffer.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/rules-unit-testing/firebase.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
{
"functions": {
"source": "."
},
"emulators": {
"firestore": {
"port": 9003
},
"database": {
"port": 9002
},
"functions": {
"port": 9004
},
"ui": {
"enabled": false
}
Expand Down
3 changes: 2 additions & 1 deletion packages/rules-unit-testing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ export {
initializeAdminApp,
initializeTestApp,
loadDatabaseRules,
loadFirestoreRules
loadFirestoreRules,
withFunctionTriggersDisabled
} from './src/api';
5 changes: 3 additions & 2 deletions packages/rules-unit-testing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See note in PR description about this new dev dependency

"rollup": "2.29.0",
"rollup-plugin-typescript2": "0.27.3"
},
Expand Down
178 changes: 114 additions & 64 deletions packages/rules-unit-testing/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -307,7 +312,7 @@ export type LoadDatabaseRulesOptions = {
databaseName: string;
rules: string;
};
export function loadDatabaseRules(
export async function loadDatabaseRules(
options: LoadDatabaseRulesOptions
): Promise<void> {
if (!options.databaseName) {
Expand All @@ -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<void> {
if (!options.projectId) {
Expand All @@ -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<void> {
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<TResult>(
fn: () => TResult | Promise<TResult>
): Promise<TResult> {
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>): any {
Expand Down Expand Up @@ -441,3 +472,22 @@ export function assertFails(pr: Promise<any>): any {
export function assertSucceeds(pr: Promise<any>): 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);
});
}
49 changes: 41 additions & 8 deletions packages/rules-unit-testing/test/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');
Expand Down Expand Up @@ -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<void>
).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 () {
Expand Down Expand Up @@ -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);
});
});
Loading