diff --git a/packages/rules-unit-testing/src/api/index.ts b/packages/rules-unit-testing/src/api/index.ts deleted file mode 100644 index 9cf12e24af9..00000000000 --- a/packages/rules-unit-testing/src/api/index.ts +++ /dev/null @@ -1,743 +0,0 @@ -/** - * @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 firebase from 'firebase'; -import 'firebase/database'; -import 'firebase/firestore'; -import 'firebase/storage'; - -import type { app } from 'firebase-admin'; -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'; -import { base64Encode } from '@firebase/util'; - -const { firestore, database, storage } = firebase; -export { firestore, database, storage }; - -/** If this environment variable is set, use it for the database emulator's address. */ -const DATABASE_ADDRESS_ENV: string = 'FIREBASE_DATABASE_EMULATOR_HOST'; -/** The default address for the local database emulator. */ -const DATABASE_ADDRESS_DEFAULT: string = 'localhost:9000'; - -/** If this environment variable is set, use it for the Firestore emulator. */ -const FIRESTORE_ADDRESS_ENV: string = 'FIRESTORE_EMULATOR_HOST'; -/** The default address for the local Firestore emulator. */ -const FIRESTORE_ADDRESS_DEFAULT: string = 'localhost:8080'; - -/** If this environment variable is set, use it for the Storage emulator. */ -const FIREBASE_STORAGE_ADDRESS_ENV: string = 'FIREBASE_STORAGE_EMULATOR_HOST'; -const CLOUD_STORAGE_ADDRESS_ENV: string = 'STORAGE_EMULATOR_HOST'; -/** The default address for the local Firestore emulator. */ -const STORAGE_ADDRESS_DEFAULT: string = 'localhost:9199'; - -/** 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; - -/** The actual address for the Firestore emulator */ -let _firestoreHost: string | undefined = undefined; - -/** The actual address for the Storage emulator */ -let _storageHost: string | undefined = undefined; - -/** The actual address for the Emulator Hub */ -let _hubHost: string | undefined = undefined; - -export type Provider = - | 'custom' - | 'email' - | 'password' - | 'phone' - | 'anonymous' - | 'google.com' - | 'facebook.com' - | 'github.com' - | 'twitter.com' - | 'microsoft.com' - | 'apple.com'; - -export type FirebaseIdToken = { - // Always set to https://securetoken.google.com/PROJECT_ID - iss: string; - - // Always set to PROJECT_ID - aud: string; - - // The user's unique id - sub: string; - - // The token issue time, in seconds since epoch - iat: number; - - // The token expiry time, normally 'iat' + 3600 - exp: number; - - // The user's unique id, must be equal to 'sub' - user_id: string; - - // The time the user authenticated, normally 'iat' - auth_time: number; - - // The sign in provider, only set when the provider is 'anonymous' - provider_id?: 'anonymous'; - - // The user's primary email - email?: string; - - // The user's email verification status - email_verified?: boolean; - - // The user's primary phone number - phone_number?: string; - - // The user's display name - name?: string; - - // The user's profile photo URL - picture?: string; - - // Information on all identities linked to this user - firebase: { - // The primary sign-in provider - sign_in_provider: Provider; - - // A map of providers to the user's list of unique identifiers from - // each provider - identities?: { [provider in Provider]?: string[] }; - }; - - // Custom claims set by the developer - [claim: string]: any; -}; - -// To avoid a breaking change, we accept the 'uid' option here, but -// new users should prefer 'sub' instead. -export type TokenOptions = Partial & { uid?: string }; - -/** - * Host/port configuration for applicable Firebase Emulators. - */ -export type FirebaseEmulatorOptions = { - firestore?: { - host: string; - port: number; - }; - database?: { - host: string; - port: number; - }; - storage?: { - host: string; - port: number; - }; - hub?: { - host: string; - port: number; - }; -}; - -function trimmedBase64Encode(val: string): string { - // Use base64url encoding and remove padding in the end (dot characters). - return base64Encode(val).replace(/\./g, ''); -} - -function createUnsecuredJwt(token: TokenOptions, projectId?: string): string { - // Unsecured JWTs use "none" as the algorithm. - const header = { - alg: 'none', - kid: 'fakekid', - type: 'JWT' - }; - - const project = projectId || 'fake-project'; - const iat = token.iat || 0; - const uid = token.sub || token.uid || token.user_id; - if (!uid) { - throw new Error("Auth must contain 'sub', 'uid', or 'user_id' field!"); - } - - const payload: FirebaseIdToken = { - // Set all required fields to decent defaults - iss: `https://securetoken.google.com/${project}`, - aud: project, - iat: iat, - exp: iat + 3600, - auth_time: iat, - sub: uid, - user_id: uid, - firebase: { - sign_in_provider: 'custom', - identities: {} - }, - - // Override with user options - ...token - }; - - // Remove the uid option since it's not actually part of the token spec. - if (payload.uid) { - delete payload.uid; - } - - // Unsecured JWTs use the empty string as a signature. - const signature = ''; - return [ - trimmedBase64Encode(JSON.stringify(header)), - trimmedBase64Encode(JSON.stringify(payload)), - signature - ].join('.'); -} -export function apps(): firebase.app.App[] { - return firebase.apps; -} - -export type AppOptions = { - databaseName?: string; - projectId?: string; - storageBucket?: string; - auth?: TokenOptions; -}; -/** Construct an App authenticated with options.auth. */ -export function initializeTestApp(options: AppOptions): firebase.app.App { - const jwt = options.auth - ? createUnsecuredJwt(options.auth, options.projectId) - : undefined; - - return initializeApp( - jwt, - options.databaseName, - options.projectId, - options.storageBucket - ); -} - -export type AdminAppOptions = { - databaseName?: string; - projectId?: string; - storageBucket?: string; -}; -/** Construct an App authenticated as an admin user. */ -export function initializeAdminApp(options: AdminAppOptions): app.App { - const admin = require('firebase-admin'); - - const app: app.App = admin.initializeApp( - getAppOptions( - options.databaseName, - options.projectId, - options.storageBucket - ), - getRandomAppName() - ); - - if (options.projectId) { - app.firestore().settings({ - host: getFirestoreHost(), - ssl: false - }); - } - - return app; -} - -/** - * Set the host and port configuration for applicable emulators. This will override any values - * found in environment variables. Must be called before initializeAdminApp or initializeTestApp. - * - * @param options options object. - */ -export function useEmulators(options: FirebaseEmulatorOptions): void { - if ( - !(options.database || options.firestore || options.storage || options.hub) - ) { - throw new Error( - "Argument to useEmulators must contain at least one of 'database', 'firestore', 'storage', or 'hub'." - ); - } - - if (options.database) { - _databaseHost = getAddress(options.database.host, options.database.port); - } - - if (options.firestore) { - _firestoreHost = getAddress(options.firestore.host, options.firestore.port); - } - - if (options.storage) { - _storageHost = getAddress(options.storage.host, options.storage.port); - } - - if (options.hub) { - _hubHost = getAddress(options.hub.host, options.hub.port); - } -} - -/** - * Use the Firebase Emulator hub to discover other running emulators. Call useEmulators() with - * the result to configure the library to use the discovered emulators. - * - * @param hubHost the host where the Emulator Hub is running (ex: 'localhost') - * @param hubPort the port where the Emulator Hub is running (ex: 4400) - */ -export async function discoverEmulators( - hubHost?: string, - hubPort?: number -): Promise { - if ((hubHost && !hubPort) || (!hubHost && hubPort)) { - throw new Error( - `Invalid configuration hubHost=${hubHost} and hubPort=${hubPort}. If either parameter is supplied, both must be defined.` - ); - } - - const hubAddress = - hubHost && hubPort ? getAddress(hubHost, hubPort) : getHubHost(); - - const res = await requestPromise(request.get, { - method: 'GET', - uri: `http://${hubAddress}/emulators` - }); - if (res.statusCode !== 200) { - throw new Error( - `HTTP Error ${res.statusCode} when attempting to reach Emulator Hub at ${hubAddress}, are you sure it is running?` - ); - } - - const options: FirebaseEmulatorOptions = {}; - - const data = JSON.parse(res.body); - - if (data.database) { - options.database = { - host: data.database.host, - port: data.database.port - }; - } - - if (data.firestore) { - options.firestore = { - host: data.firestore.host, - port: data.firestore.port - }; - } - - if (data.storage) { - options.storage = { - host: data.storage.host, - port: data.storage.port - }; - } - - if (data.hub) { - options.hub = { - host: data.hub.host, - port: data.hub.port - }; - } - - return options; -} - -function getAddress(host: string, port: number) { - if (host.includes('::')) { - return `[${host}]:${port}`; - } else { - return `${host}:${port}`; - } -} - -function getDatabaseHost() { - if (!_databaseHost) { - const fromEnv = process.env[DATABASE_ADDRESS_ENV]; - if (fromEnv) { - _databaseHost = fromEnv; - } else { - console.warn( - `Warning: ${DATABASE_ADDRESS_ENV} not set, using default value ${DATABASE_ADDRESS_DEFAULT}` - ); - _databaseHost = DATABASE_ADDRESS_DEFAULT; - } - } - - return _databaseHost; -} - -function getFirestoreHost() { - if (!_firestoreHost) { - const fromEnv = process.env[FIRESTORE_ADDRESS_ENV]; - if (fromEnv) { - _firestoreHost = fromEnv; - } else { - console.warn( - `Warning: ${FIRESTORE_ADDRESS_ENV} not set, using default value ${FIRESTORE_ADDRESS_DEFAULT}` - ); - _firestoreHost = FIRESTORE_ADDRESS_DEFAULT; - } - } - - return _firestoreHost; -} - -function getStorageHost() { - if (!_storageHost) { - const fromEnv = - process.env[FIREBASE_STORAGE_ADDRESS_ENV] || - process.env[CLOUD_STORAGE_ADDRESS_ENV]; - if (fromEnv) { - // The STORAGE_EMULATOR_HOST env var is an older Cloud Standard which includes http:// while - // the FIREBASE_STORAGE_EMULATOR_HOST is a newer variable supported beginning in the Admin - // SDK v9.7.0 which does not have the protocol. - _storageHost = fromEnv.replace('http://', ''); - } else { - console.warn( - `Warning: ${FIREBASE_STORAGE_ADDRESS_ENV} not set, using default value ${STORAGE_ADDRESS_DEFAULT}` - ); - _storageHost = STORAGE_ADDRESS_DEFAULT; - } - } - - return _storageHost; -} - -function getHubHost() { - if (!_hubHost) { - const fromEnv = process.env[HUB_HOST_ENV]; - if (fromEnv) { - _hubHost = fromEnv; - } else { - console.warn( - `Warning: ${HUB_HOST_ENV} not set, using default value ${HUB_HOST_DEFAULT}` - ); - _hubHost = HUB_HOST_DEFAULT; - } - } - - return _hubHost; -} - -function parseHost(host: string): { hostname: string; port: number } { - const withProtocol = host.startsWith('http') ? host : `http://${host}`; - const u = new URL(withProtocol); - return { - hostname: u.hostname, - port: Number.parseInt(u.port, 10) - }; -} - -function getRandomAppName(): string { - return 'app-' + new Date().getTime() + '-' + Math.random(); -} - -function getDatabaseUrl(databaseName: string) { - return `http://${getDatabaseHost()}?ns=${databaseName}`; -} - -function getAppOptions( - databaseName?: string, - projectId?: string, - storageBucket?: string -): { [key: string]: string } { - let appOptions: { [key: string]: string } = {}; - - if (databaseName) { - appOptions['databaseURL'] = getDatabaseUrl(databaseName); - } - - if (projectId) { - appOptions['projectId'] = projectId; - } - - if (storageBucket) { - appOptions['storageBucket'] = storageBucket; - } - - return appOptions; -} - -function initializeApp( - accessToken?: string, - databaseName?: string, - projectId?: string, - storageBucket?: string -): firebase.app.App { - const appOptions = getAppOptions(databaseName, projectId, storageBucket); - const app = firebase.initializeApp(appOptions, getRandomAppName()); - 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) { - const { hostname, port } = parseHost(getDatabaseHost()); - app.database().useEmulator(hostname, port); - - // 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) { - const { hostname, port } = parseHost(getFirestoreHost()); - app.firestore().useEmulator(hostname, port); - } - if (storageBucket) { - const { hostname, port } = parseHost(getStorageHost()); - app.storage().useEmulator(hostname, port); - } - /** - 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 async function loadDatabaseRules( - options: LoadDatabaseRulesOptions -): Promise { - if (!options.databaseName) { - throw Error('databaseName not specified'); - } - - if (!options.rules) { - throw Error('must provide rules to loadDatabaseRules'); - } - - 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 async 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'); - } - - 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 LoadStorageRulesOptions = { - rules: string; -}; -export async function loadStorageRules( - options: LoadStorageRulesOptions -): Promise { - if (!options.rules) { - throw new Error('must provide rules to loadStorageRules'); - } - - const resp = await requestPromise(request.put, { - method: 'PUT', - uri: `http://${getStorageHost()}/internal/setRules`, - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - rules: { - files: [{ name: 'storage.rules', content: options.rules }] - } - }) - }); - - if (resp.statusCode !== 200) { - throw new Error(resp.body); - } -} - -export type ClearFirestoreDataOptions = { - projectId: string; -}; -export async function clearFirestoreData( - options: ClearFirestoreDataOptions -): Promise { - if (!options.projectId) { - throw new Error('projectId not specified'); - } - - 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 { - const hubHost = getHubHost(); - - // 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 { - return pr.then( - (v: any) => { - return Promise.reject( - new Error('Expected request to fail, but it succeeded.') - ); - }, - (err: any) => { - const errCode = (err && err.code && err.code.toLowerCase()) || ''; - const errMessage = - (err && err.message && err.message.toLowerCase()) || ''; - const isPermissionDenied = - errCode === 'permission-denied' || - errCode === 'permission_denied' || - errMessage.indexOf('permission_denied') >= 0 || - errMessage.indexOf('permission denied') >= 0 || - // Storage permission errors contain message: (storage/unauthorized) - errMessage.indexOf('unauthorized') >= 0; - - if (!isPermissionDenied) { - return Promise.reject( - new Error( - `Expected PERMISSION_DENIED but got unexpected error: ${err}` - ) - ); - } - return err; - } - ); -} - -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/src/impl/test_environment.ts b/packages/rules-unit-testing/src/impl/test_environment.ts new file mode 100644 index 00000000000..d75578d3e7b --- /dev/null +++ b/packages/rules-unit-testing/src/impl/test_environment.ts @@ -0,0 +1,233 @@ +/** + * @license + * Copyright 2021 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 firebase from 'firebase/compat/app'; +import 'firebase/firestore/compat'; +import 'firebase/database/compat'; +import 'firebase/storage/compat'; + +import { + HostAndPort, + RulesTestContext, + RulesTestEnvironment, + TokenOptions +} from '../public_types'; + +import { DiscoveredEmulators } from './discovery'; + +/** + * An implementation of {@code RulesTestEnvironment}. This is private to hide the constructor, + * which should never be directly called by the developer. + * @private + */ +export class RulesTestEnvironmentImpl implements RulesTestEnvironment { + private contexts = new Set(); + private destroyed = false; + + constructor( + readonly projectId: string, + readonly emulators: DiscoveredEmulators + ) {} + + authenticatedContext( + user_id: string, + tokenOptions?: TokenOptions + ): RulesTestContext { + this.checkNotDestroyed(); + return this.createContext({ + ...tokenOptions, + sub: user_id, + user_id: user_id + }); + } + + unauthenticatedContext(): RulesTestContext { + this.checkNotDestroyed(); + return this.createContext(/* authToken = */ undefined); + } + + async withSecurityRulesDisabled( + callback: (context: RulesTestContext) => Promise + ): Promise { + this.checkNotDestroyed(); + // The "owner" token is recognized by the emulators as a special value that bypasses Security + // Rules. This should only ever be used in withSecurityRulesDisabled. + // If you're reading this and thinking about doing this in your own app / tests / scripts, think + // twice. Instead, just use withSecurityRulesDisabled for unit testing OR connect your Firebase + // Admin SDKs to the emulators for integration testing via environment variables. + // See: https://firebase.google.com/docs/emulator-suite/connect_firestore#admin_sdks + const context = this.createContext('owner'); + try { + await callback(context); + } finally { + // We eagarly clean up this context to actively prevent misuse outside of the callback, e.g. + // storing the context in a variable. + context.cleanup(); + this.contexts.delete(context); + } + } + + private createContext( + authToken: string | firebase.EmulatorMockTokenOptions | undefined + ): RulesTestContextImpl { + const context = new RulesTestContextImpl( + this.projectId, + this.emulators, + authToken + ); + this.contexts.add(context); + return context; + } + + clearDatabase(): Promise { + this.checkNotDestroyed(); + return this.withSecurityRulesDisabled(context => { + return context.database().ref('/').set(null); + }); + } + + clearFirestore(): Promise { + this.checkNotDestroyed(); + throw new Error('Method not implemented.'); + } + + clearStorage(): Promise { + this.checkNotDestroyed(); + throw new Error('Method not implemented.'); + } + + async cleanup(): Promise { + this.destroyed = true; + this.contexts.forEach(context => { + context.envDestroyed = true; + context.cleanup(); + }); + this.contexts.clear(); + } + + private checkNotDestroyed() { + if (this.destroyed) { + throw new Error( + 'This RulesTestEnvironment has already been cleaned up. ' + + '(This may indicate a leak or missing `await` in your test cases. If you do intend to ' + + 'perform more tests, please call cleanup() later or create another RulesTestEnvironment.)' + ); + } + } +} +/** + * An implementation of {@code RulesTestContext}. This is private to hide the constructor, + * which should never be directly called by the developer. + * @private + */ +class RulesTestContextImpl implements RulesTestContext { + private app?: firebase.app.App; + private destroyed = false; + envDestroyed = false; + + constructor( + readonly projectId: string, + readonly emulators: DiscoveredEmulators, + readonly authToken: firebase.EmulatorMockTokenOptions | string | undefined + ) {} + + cleanup() { + this.destroyed = true; + this.app?.delete(); + + this.app = undefined; + } + + firestore( + settings?: firebase.firestore.Settings + ): firebase.firestore.Firestore { + assertEmulatorRunning(this.emulators, 'firestore'); + const firestore = this.getApp().firestore(); + if (settings) { + firestore.settings(settings); + } + firestore.useEmulator( + this.emulators.firestore.host, + this.emulators.firestore.port, + { mockUserToken: this.authToken } + ); + return firestore; + } + database(databaseURL?: string): firebase.database.Database { + assertEmulatorRunning(this.emulators, 'database'); + const database = this.getApp().database(databaseURL); + database.useEmulator( + this.emulators.database.host, + this.emulators.database.port, + { mockUserToken: this.authToken } + ); + return database; + } + storage(bucketUrl?: string): firebase.storage.Storage { + assertEmulatorRunning(this.emulators, 'storage'); + const storage = this.getApp().storage(bucketUrl); + storage.useEmulator( + this.emulators.storage.host, + this.emulators.storage.port, + { mockUserToken: this.authToken } + ); + return storage; + } + + private getApp(): firebase.app.App { + if (this.envDestroyed) { + throw new Error( + 'This RulesTestContext is no longer valid because its RulesTestEnvironment has been ' + + 'cleaned up. (This may indicate a leak or missing `await` in your test cases.)' + ); + } + if (this.destroyed) { + throw new Error( + 'This RulesTestContext is no longer valid. When using withSecurityRulesDisabled, ' + + 'make sure to perform all operations on the context within the callback function and ' + + 'return a Promise that resolves when the operations are done.' + ); + } + if (!this.app) { + this.app = firebase.initializeApp( + { projectId: this.projectId }, + `_Firebase_RulesUnitTesting_${Date.now()}_${Math.random()}` + ); + } + return this.app; + } +} + +export function assertEmulatorRunning( + emulators: DiscoveredEmulators, + emulator: E +): asserts emulators is Record { + if (!emulators[emulator]) { + if (emulators.hub) { + throw new Error( + `The ${emulator} emulator is not running (according to Emulator hub). To force ` + + 'connecting anyway, please specify its host and port in initializeTestEnvironment({...}).' + ); + } else { + throw new Error( + `The host and port of the ${emulator} emulator must be specified. (You may wrap the test ` + + "script with `firebase emulators:exec './your-test-script'` to enable automatic " + + `discovery, or specify manually via initializeTestEnvironment({${emulator}: {host, port}}).` + ); + } + } +} diff --git a/packages/rules-unit-testing/src/initialize.ts b/packages/rules-unit-testing/src/initialize.ts index fd9a3cffa3d..9d7b3bc9dfc 100644 --- a/packages/rules-unit-testing/src/initialize.ts +++ b/packages/rules-unit-testing/src/initialize.ts @@ -21,6 +21,7 @@ import { discoverEmulators, getEmulatorHostAndPort } from './impl/discovery'; +import { RulesTestEnvironmentImpl } from './impl/test_environment'; /** * Initializes a test environment for rules unit testing. Call this function first for test setup. @@ -72,8 +73,8 @@ export async function initializeTestEnvironment( } } - // TODO: Create test env and set security rules. - throw new Error('unimplemented'); + // TODO: Set security rules. + return new RulesTestEnvironmentImpl(projectId, emulators); } const SUPPORTED_EMULATORS = ['database', 'firestore', 'storage'] as const; diff --git a/packages/rules-unit-testing/src/public_types/index.ts b/packages/rules-unit-testing/src/public_types/index.ts index 0cdec67491a..52c2410dcb5 100644 --- a/packages/rules-unit-testing/src/public_types/index.ts +++ b/packages/rules-unit-testing/src/public_types/index.ts @@ -16,9 +16,7 @@ */ import { FirebaseSignInProvider } from '@firebase/util'; -import { Firestore, FirestoreSettings } from '@firebase/firestore/exp'; -import { Database } from '@firebase/database/exp'; -import { FirebaseStorage } from '@firebase/storage/exp'; +import firebase from 'firebase/compat/app'; /** * More options for the mock user token to be used for testing, including developer-specfied custom @@ -251,7 +249,9 @@ export interface RulesTestContext { * @returns a Firestore instance configured to connect to the emulator * @public */ - firestore(settings?: FirestoreSettings): Firestore; + firestore( + settings?: firebase.firestore.Settings + ): firebase.firestore.Firestore; /** * Get a Firestore instance for this test context. The returned Firebase JS Client SDK instance @@ -263,7 +263,7 @@ export interface RulesTestContext { * @returns a Database instance configured to connect to the emulator. It never connects to * production even if a production databaseURL is specified */ - database(databaseURL?: string): Database; + database(databaseURL?: string): firebase.database.Database; /** * Get a Storage instance for this test context. The returned Firebase JS Client SDK instance @@ -274,5 +274,5 @@ export interface RulesTestContext { * returns a Storage instance for an emulated version of the bucket name * @returns a Storage instance configured to connect to the emulator */ - storage(bucketUrl?: string): FirebaseStorage; + storage(bucketUrl?: string): firebase.storage.Storage; } diff --git a/packages/rules-unit-testing/src/util.ts b/packages/rules-unit-testing/src/util.ts index 8822e73b91e..49b448b0913 100644 --- a/packages/rules-unit-testing/src/util.ts +++ b/packages/rules-unit-testing/src/util.ts @@ -70,7 +70,33 @@ export async function withFunctionTriggersDisabled( * ``` */ export function assertFails(pr: Promise): Promise { - throw new Error('unimplemented'); + return pr.then( + () => { + return Promise.reject( + new Error('Expected request to fail, but it succeeded.') + ); + }, + (err: any) => { + const errCode = err?.code?.toLowerCase() || ''; + const errMessage = err?.message?.toLowerCase() || ''; + const isPermissionDenied = + errCode === 'permission-denied' || + errCode === 'permission_denied' || + errMessage.indexOf('permission_denied') >= 0 || + errMessage.indexOf('permission denied') >= 0 || + // Storage permission errors contain message: (storage/unauthorized) + errMessage.indexOf('unauthorized') >= 0; + + if (!isPermissionDenied) { + return Promise.reject( + new Error( + `Expected PERMISSION_DENIED but got unexpected error: ${err}` + ) + ); + } + return err; + } + ); } /** diff --git a/packages/rules-unit-testing/test/util.test.ts b/packages/rules-unit-testing/test/util.test.ts new file mode 100644 index 00000000000..5db60441e9d --- /dev/null +++ b/packages/rules-unit-testing/test/util.test.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2021 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 { assertFails, assertSucceeds } from '../src/util'; + +describe('assertSucceeds()', () => { + it('returns a fulfilled promise iff success', async function () { + const success = Promise.resolve('success'); + const failure = Promise.reject('failure'); + await assertSucceeds(success).catch(() => { + throw new Error('Expected success to succeed.'); + }); + await assertSucceeds(failure) + .then(() => { + throw new Error('Expected failure to fail.'); + }) + .catch(() => {}); + }); +}); + +describe('assertFails()', () => { + it('returns a rejected promise if argument promise fulfills', async function () { + const success = Promise.resolve('success'); + await assertFails(success) + .then(() => { + throw new Error('Expected success to fail.'); + }) + .catch(() => {}); + }); + + it('returns a fulfilled promise if PERMISSION_DENIED', async function () { + const permissionDenied = Promise.reject({ + message: 'PERMISSION_DENIED' + }); + + await assertFails(permissionDenied).catch(() => { + throw new Error('Expected permissionDenied to succeed.'); + }); + }); + + it('returns a fulfilled promise if code is permission-denied', async function () { + const success = Promise.resolve('success'); + const permissionDenied = Promise.reject({ + code: 'permission-denied' + }); + const otherFailure = Promise.reject('failure'); + await assertFails(success) + .then(() => { + throw new Error('Expected success to fail.'); + }) + .catch(() => {}); + + await assertFails(permissionDenied).catch(() => { + throw new Error('Expected permissionDenied to succeed.'); + }); + + await assertFails(otherFailure) + .then(() => { + throw new Error('Expected otherFailure to fail.'); + }) + .catch(() => {}); + }); + + it('resolve if code is PERMISSION_DENIED', async function () { + const success = Promise.resolve('success'); + const permissionDenied = Promise.reject({ + code: 'PERMISSION_DENIED' + }); + const otherFailure = Promise.reject('failure'); + await assertFails(success) + .then(() => { + throw new Error('Expected success to fail.'); + }) + .catch(() => {}); + + await assertFails(permissionDenied).catch(() => { + throw new Error('Expected permissionDenied to succeed.'); + }); + + await assertFails(otherFailure) + .then(() => { + throw new Error('Expected otherFailure to fail.'); + }) + .catch(() => {}); + }); + + it('returns a fulfilled promise if message is Permission denied', async function () { + const success = Promise.resolve('success'); + const permissionDenied = Promise.reject({ + message: 'Permission denied' + }); + const otherFailure = Promise.reject('failure'); + await assertFails(success) + .then(() => { + throw new Error('Expected success to fail.'); + }) + .catch(() => {}); + + await assertFails(permissionDenied).catch(() => { + throw new Error('Expected permissionDenied to succeed.'); + }); + + await assertFails(otherFailure) + .then(() => { + throw new Error('Expected otherFailure to fail.'); + }) + .catch(() => {}); + }); + + it('returns a fulfilled promise if message contains unauthorized', async function () { + const success = Promise.resolve('success'); + const permissionDenied = Promise.reject({ + message: + "User does not have permission to access 'file'. (storage/unauthorized)" + }); + const otherFailure = Promise.reject('failure'); + await assertFails(success) + .then(() => { + throw new Error('Expected success to fail.'); + }) + .catch(() => {}); + + await assertFails(permissionDenied).catch(() => { + throw new Error('Expected permissionDenied to succeed.'); + }); + + await assertFails(otherFailure) + .then(() => { + throw new Error('Expected otherFailure to fail.'); + }) + .catch(() => {}); + }); + + it('returns a rejected promise if argument promise rejects with other errors', async function () { + const otherFailure = Promise.reject('failure'); + + await assertFails(otherFailure) + .then(() => { + throw new Error('Expected otherFailure to fail.'); + }) + .catch(() => {}); + }); +});