diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b8deabaad13..72077cf6760 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -59,6 +59,7 @@ packages/auth @bojeil-google @avolkovi @samhorlbeck @scottcrossen @firebase/jss packages/auth-types @bojeil-google @avolkovi @samhorlbeck @scottcrossen @firebase/jssdk-global-approvers # Testing Code +packages/testing @avolkovi @samhorlbeck @scottcrossen @yuchenshi @firebase/jssdk-global-approvers packages/rules-unit-testing @avolkovi @samhorlbeck @scottcrossen @yuchenshi @firebase/jssdk-global-approvers # RxFire Code diff --git a/packages/testing/.firebaserc b/packages/testing/.firebaserc new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/packages/testing/.firebaserc @@ -0,0 +1 @@ +{} diff --git a/packages/testing/CHANGELOG.md b/packages/testing/CHANGELOG.md new file mode 100644 index 00000000000..99a599c19a5 --- /dev/null +++ b/packages/testing/CHANGELOG.md @@ -0,0 +1,31 @@ +# @firebase/testing + +## 0.20.9 + +### Patch Changes + +- Updated dependencies [[`a87676b8`](https://github.com/firebase/firebase-js-sdk/commit/a87676b84b78ccc2f057a22eb947a5d13402949c)]: + - @firebase/util@0.3.0 + - firebase@7.17.1 + +## 0.20.8 + +### Patch Changes + +- Updated dependencies [[`02419ce8`](https://github.com/firebase/firebase-js-sdk/commit/02419ce8470141f012d9ce425a6a4a4aa912e480)]: + - firebase@7.17.0 + +## 0.20.7 + +### Patch Changes + +- Updated dependencies [[`9c409ea7`](https://github.com/firebase/firebase-js-sdk/commit/9c409ea74efd00fe17058c5c8b74450fae67e9ee), [`5a355360`](https://github.com/firebase/firebase-js-sdk/commit/5a3553609da893d45f7fe1897387f72eaedf2fe0), [`c2b737b2`](https://github.com/firebase/firebase-js-sdk/commit/c2b737b2187cb525af4d926ca477102db7835420), [`9a9a81fe`](https://github.com/firebase/firebase-js-sdk/commit/9a9a81fe4f001f23e9fe1db054c2e7159fca3ae3)]: + - firebase@7.16.1 + +## 0.20.6 + +### Patch Changes + +- Updated dependencies [[`a754645e`](https://github.com/firebase/firebase-js-sdk/commit/a754645ec2be1b8c205f25f510196eee298b0d6e), [`17c628eb`](https://github.com/firebase/firebase-js-sdk/commit/17c628eb228c21ad1d4db83fdae08d1142a2b902), [`bb740836`](https://github.com/firebase/firebase-js-sdk/commit/bb7408361519aa9a58c8256ae01914cf2830e118), [`39ca8ecf`](https://github.com/firebase/firebase-js-sdk/commit/39ca8ecf940472159d0bc58212f34a70146da60c), [`877c060c`](https://github.com/firebase/firebase-js-sdk/commit/877c060c47bb29a8efbd2b96d35d3334fd9d9a98), [`e90304c8`](https://github.com/firebase/firebase-js-sdk/commit/e90304c8ac4341d8b23b55da784eb21348b04025), [`469c8bdf`](https://github.com/firebase/firebase-js-sdk/commit/469c8bdf18c4a22e99d595a9896af2f934df20fd)]: + - firebase@7.16.0 + - @firebase/logger@0.2.6 diff --git a/packages/testing/README.md b/packages/testing/README.md new file mode 100644 index 00000000000..40a72bb086d --- /dev/null +++ b/packages/testing/README.md @@ -0,0 +1,9 @@ +# @firebase/testing + +A set of utilities useful for testing Security Rules with the Realtime Database or Cloud Firestore +emulators. + +See: + + * [Test your Cloud Firestore Security Rules](https://firebase.google.com/docs/firestore/security/test-rules-emulator) + * [Testing Security Rules with the Realtime Database Emulator](https://firebase.google.com/docs/database/security/test-rules-emulator) \ No newline at end of file diff --git a/packages/testing/firebase.json b/packages/testing/firebase.json new file mode 100644 index 00000000000..b2ba04fe7ea --- /dev/null +++ b/packages/testing/firebase.json @@ -0,0 +1,13 @@ +{ + "emulators": { + "firestore": { + "port": 9001 + }, + "database": { + "port": 9000 + }, + "ui": { + "enabled": false + } + } +} diff --git a/packages/testing/index.ts b/packages/testing/index.ts new file mode 100644 index 00000000000..4c5c8a27bac --- /dev/null +++ b/packages/testing/index.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2017 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * The testing module does not need to be registered since it should not ever + * come by default. The only way to use the testing module is by explicitly + * creating a dependency on @firebase/testing. + */ + +export { + apps, + assertFails, + assertSucceeds, + clearFirestoreData, + database, + firestore, + initializeAdminApp, + initializeTestApp, + loadDatabaseRules, + loadFirestoreRules +} from './src/api'; diff --git a/packages/testing/package.json b/packages/testing/package.json new file mode 100644 index 00000000000..8007fd4504e --- /dev/null +++ b/packages/testing/package.json @@ -0,0 +1,42 @@ +{ + "name": "@firebase/testing", + "version": "0.20.9", + "description": "", + "author": "Firebase (https://firebase.google.com/)", + "main": "dist/index.cjs.js", + "engines": { + "node": "^8.13.0 || >=10.10.0" + }, + "files": ["dist"], + "scripts": { + "build": "rollup -c", + "build:deps": "lerna run --scope @firebase/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 emulators:exec 'yarn test:nyc'", + "test:ci": "node ../../scripts/run_tests_in_ci.js", + "prepare": "yarn build" + }, + "license": "Apache-2.0", + "dependencies": { + "firebase": "7.17.1", + "@firebase/logger": "0.2.6", + "@firebase/util": "0.3.0", + "request": "2.88.2" + }, + "devDependencies": { + "@types/request": "2.48.5", + "firebase-tools": "8.6.0", + "rollup": "2.23.0", + "rollup-plugin-typescript2": "0.27.1" + }, + "repository": { + "directory": "packages/testing", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "typings": "dist/index.d.ts", + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + } +} diff --git a/packages/testing/rollup.config.js b/packages/testing/rollup.config.js new file mode 100644 index 00000000000..d1399baf786 --- /dev/null +++ b/packages/testing/rollup.config.js @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import typescriptPlugin from 'rollup-plugin-typescript2'; +import pkg from './package.json'; +import typescript from 'typescript'; + +const plugins = [ + typescriptPlugin({ + typescript + }) +]; + +const deps = Object.keys( + Object.assign({}, pkg.peerDependencies, pkg.dependencies) +); + +export default { + input: 'index.ts', + output: [{ file: pkg.main, format: 'cjs', sourcemap: true }], + plugins: [...plugins], + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) +}; diff --git a/packages/testing/src/api/index.ts b/packages/testing/src/api/index.ts new file mode 100644 index 00000000000..c5207de5b17 --- /dev/null +++ b/packages/testing/src/api/index.ts @@ -0,0 +1,272 @@ +/** + * @license + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as firebase from 'firebase'; +import { _FirebaseApp } from '@firebase/app-types/private'; +import { FirebaseAuthInternal } from '@firebase/auth-interop-types'; +import * as request from 'request'; +import { base64 } from '@firebase/util'; +import { setLogLevel, LogLevel } from '@firebase/logger'; +import { Component, ComponentType } from '@firebase/component'; + +export { database, firestore } from 'firebase'; + +/** If this environment variable is set, use it for the database emulator's address. */ +const DATABASE_ADDRESS_ENV: string = 'FIREBASE_DATABASE_EMULATOR_ADDRESS'; +/** The default address for the local database emulator. */ +const DATABASE_ADDRESS_DEFAULT: string = 'localhost:9000'; +/** The actual address for the database emulator */ +const DATABASE_ADDRESS: string = + process.env[DATABASE_ADDRESS_ENV] || DATABASE_ADDRESS_DEFAULT; + +/** If any of environment variable is set, use it for the Firestore emulator. */ +const FIRESTORE_ADDRESS_ENVS: string[] = [ + 'FIRESTORE_EMULATOR_HOST', + 'FIREBASE_FIRESTORE_EMULATOR_ADDRESS' +]; +/** The default address for the local Firestore emulator. */ +const FIRESTORE_ADDRESS_DEFAULT: string = 'localhost:8080'; +/** The actual address for the Firestore emulator */ +const FIRESTORE_ADDRESS: string = FIRESTORE_ADDRESS_ENVS.reduce( + (addr, name) => process.env[name] || addr, + FIRESTORE_ADDRESS_DEFAULT +); + +/** Passing this in tells the emulator to treat you as an admin. */ +const ADMIN_TOKEN = 'owner'; +/** Create an unsecured JWT for the given auth payload. See https://tools.ietf.org/html/rfc7519#section-6. */ +function createUnsecuredJwt(auth: object): string { + // Unsecured JWTs use "none" as the algorithm. + const header = { + alg: 'none', + kid: 'fakekid' + }; + // Ensure that the auth payload has a value for 'iat'. + (auth as any).iat = (auth as any).iat || 0; + // Use `uid` field as a backup when `sub` is missing. + (auth as any).sub = (auth as any).sub || (auth as any).uid; + if (!(auth as any).sub) { + throw new Error("auth must be an object with a 'sub' or 'uid' field"); + } + // Unsecured JWTs use the empty string as a signature. + const signature = ''; + return [ + base64.encodeString(JSON.stringify(header), /*webSafe=*/ false), + base64.encodeString(JSON.stringify(auth), /*webSafe=*/ false), + signature + ].join('.'); +} + +export function apps(): firebase.app.App[] { + return firebase.apps; +} + +export type AppOptions = { + databaseName?: string; + projectId?: string; + auth?: object; +}; +/** Construct an App authenticated with options.auth. */ +export function initializeTestApp(options: AppOptions): firebase.app.App { + return initializeApp( + options.auth ? createUnsecuredJwt(options.auth) : undefined, + options.databaseName, + options.projectId + ); +} + +export type AdminAppOptions = { + databaseName?: string; + projectId?: string; +}; +/** Construct an App authenticated as an admin user. */ +export function initializeAdminApp(options: AdminAppOptions): firebase.app.App { + return initializeApp(ADMIN_TOKEN, options.databaseName, options.projectId); +} + +function initializeApp( + accessToken?: string, + databaseName?: string, + projectId?: string +): firebase.app.App { + let appOptions: { [key: string]: string } = {}; + if (databaseName) { + appOptions['databaseURL'] = `http://${DATABASE_ADDRESS}?ns=${databaseName}`; + } + if (projectId) { + appOptions['projectId'] = projectId; + } + const appName = 'app-' + new Date().getTime() + '-' + Math.random(); + let app = firebase.initializeApp(appOptions, appName); + if (accessToken) { + const mockAuthComponent = new Component( + 'auth-internal', + () => + ({ + getToken: async () => ({ accessToken: accessToken }), + getUid: () => null, + addAuthTokenListener: listener => { + // Call listener once immediately with predefined accessToken. + listener(accessToken); + }, + removeAuthTokenListener: () => {} + } as FirebaseAuthInternal), + ComponentType.PRIVATE + ); + + ((app as unknown) as _FirebaseApp)._addOrOverwriteComponent( + mockAuthComponent + ); + } + if (databaseName) { + // Toggle network connectivity to force a reauthentication attempt. + // This mitigates a minor race condition where the client can send the + // first database request before authenticating. + app.database().goOffline(); + app.database().goOnline(); + } + if (projectId) { + app.firestore().settings({ + host: FIRESTORE_ADDRESS, + ssl: false + }); + } + /** + Mute warnings for the previously-created database and whatever other + objects were just created. + */ + setLogLevel(LogLevel.ERROR); + return app; +} + +export type LoadDatabaseRulesOptions = { + databaseName: string; + rules: string; +}; +export function loadDatabaseRules( + options: LoadDatabaseRulesOptions +): Promise { + if (!options.databaseName) { + throw Error('databaseName not specified'); + } + + if (!options.rules) { + throw Error('must provide rules to loadDatabaseRules'); + } + + return new Promise((resolve, reject) => { + request.put( + { + uri: `http://${DATABASE_ADDRESS}/.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(); + } + } + ); + }); +} + +export type LoadFirestoreRulesOptions = { + projectId: string; + rules: string; +}; +export function loadFirestoreRules( + options: LoadFirestoreRulesOptions +): Promise { + if (!options.projectId) { + throw new Error('projectId not specified'); + } + + if (!options.rules) { + throw new Error('must provide rules to loadFirestoreRules'); + } + + return new Promise((resolve, reject) => { + request.put( + { + uri: `http://${FIRESTORE_ADDRESS}/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(); + } + } + ); + }); +} + +export type ClearFirestoreDataOptions = { + projectId: string; +}; +export function clearFirestoreData( + options: ClearFirestoreDataOptions +): Promise { + if (!options.projectId) { + throw new Error('projectId not specified'); + } + + return new Promise((resolve, reject) => { + request.delete( + { + uri: `http://${FIRESTORE_ADDRESS}/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(); + } + } + ); + }); +} + +export function assertFails(pr: Promise): any { + return pr.then( + v => + Promise.reject(new Error('Expected request to fail, but it succeeded.')), + err => err + ); +} + +export function assertSucceeds(pr: Promise): any { + return pr; +} diff --git a/packages/testing/test/database.test.ts b/packages/testing/test/database.test.ts new file mode 100644 index 00000000000..034d941a6a8 --- /dev/null +++ b/packages/testing/test/database.test.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2017 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as chai from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import * as firebase from '../src/api'; +import { base64 } from '@firebase/util'; +import { _FirebaseApp } from '@firebase/app-types/private'; + +const expect = chai.expect; + +before(() => { + chai.use(chaiAsPromised); +}); + +describe('Testing Module Tests', function () { + it('assertSucceeds() iff success', async function () { + const success = Promise.resolve('success'); + const failure = Promise.reject('failure'); + await firebase.assertSucceeds(success).catch(() => { + throw new Error('Expected success to succeed.'); + }); + await firebase + .assertSucceeds(failure) + .then(() => { + throw new Error('Expected failure to fail.'); + }) + .catch(() => {}); + }); + + it('assertFails() iff failure', async function () { + const success = Promise.resolve('success'); + const failure = Promise.reject('failure'); + await firebase + .assertFails(success) + .then(() => { + throw new Error('Expected success to fail.'); + }) + .catch(() => {}); + await firebase.assertFails(failure).catch(() => { + throw new Error('Expected failure to succeed.'); + }); + }); + + it('initializeTestApp() with auth=null does not set access token', async function () { + const app = firebase.initializeTestApp({ + projectId: 'foo', + auth: undefined + }); + + const authInternal = ((app as unknown) as _FirebaseApp).container + .getProvider('auth-internal') + .getImmediate({ optional: true }); + // Auth instance will not be available because no API Key is provided + expect(authInternal).to.be.null; + }); + + it('initializeTestApp() with auth sets the correct access token', async function () { + const auth = { uid: 'alice' }; + const app = firebase.initializeTestApp({ + projectId: 'foo', + auth: auth + }); + const authInternal = ((app as unknown) as _FirebaseApp).container + .getProvider('auth-internal') + .getImmediate(); + + const token = await authInternal.getToken(); + expect(token).to.have.keys('accessToken'); + const claims = JSON.parse( + base64.decodeString(token!.accessToken.split('.')[1], /*webSafe=*/ false) + ); + // We add an 'iat' field. + expect(claims).to.deep.equal({ uid: auth.uid, iat: 0, sub: auth.uid }); + }); + + it('initializeAdminApp() sets the access token to "owner"', async function () { + const app = firebase.initializeAdminApp({ projectId: 'foo' }); + const authInternal = ((app as unknown) as _FirebaseApp).container + .getProvider('auth-internal') + .getImmediate(); + + const token = await authInternal.getToken(); + expect(token).to.have.keys('accessToken'); + expect(token!.accessToken).to.be.string('owner'); + }); + + 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/ + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await expect( + (firebase as any).loadDatabaseRules.bind(null, { + databaseName: 'foo' + }) as Promise + ).to.throw(/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/); + }); + + it('loadDatabaseRules() succeeds on valid input', async function () { + await firebase.loadDatabaseRules({ + databaseName: 'foo', + rules: '{ "rules": {} }' + }); + }); + + it('loadFirestoreRules() succeeds on valid input', async function () { + await firebase.loadFirestoreRules({ + projectId: 'foo', + rules: `service cloud.firestore { + match /databases/{db}/documents/{doc=**} { + allow read, write; + } + }` + }); + }); + + it('clearFirestoreData() succeeds on valid input', async function () { + await firebase.clearFirestoreData({ + projectId: 'foo' + }); + }); + + it('apps() returns apps created with initializeTestApp', async function () { + const numApps = firebase.apps().length; + await firebase.initializeTestApp({ databaseName: 'foo', auth: undefined }); + expect(firebase.apps().length).to.equal(numApps + 1); + await firebase.initializeTestApp({ databaseName: 'bar', auth: undefined }); + expect(firebase.apps().length).to.equal(numApps + 2); + }); + + it('there is a way to get database timestamps', function () { + expect(firebase.database.ServerValue.TIMESTAMP).to.deep.equal({ + '.sv': 'timestamp' + }); + }); + + it('there is a way to get firestore timestamps', function () { + expect(firebase.firestore.FieldValue.serverTimestamp()).not.to.be.null; + }); +}); diff --git a/packages/testing/tsconfig.json b/packages/testing/tsconfig.json new file mode 100644 index 00000000000..09f747b4d46 --- /dev/null +++ b/packages/testing/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "exclude": [ + "dist/**/*" + ] +}