diff --git a/.changeset/moody-suits-punch.md b/.changeset/moody-suits-punch.md new file mode 100644 index 00000000000..e874ce2c1b0 --- /dev/null +++ b/.changeset/moody-suits-punch.md @@ -0,0 +1,5 @@ +--- +'@firebase/rules-unit-testing': minor +--- + +Add support for Storage emulator to rules-unit-testing diff --git a/packages/rules-unit-testing/firebase.json b/packages/rules-unit-testing/firebase.json index 1b237fe5922..2f2052323a5 100644 --- a/packages/rules-unit-testing/firebase.json +++ b/packages/rules-unit-testing/firebase.json @@ -2,6 +2,9 @@ "functions": { "source": "." }, + "storage": { + "rules": "test/storage.rules" + }, "emulators": { "firestore": { "port": 9003 @@ -12,6 +15,9 @@ "functions": { "port": 9004 }, + "storage": { + "port": 9199 + }, "ui": { "enabled": false } diff --git a/packages/rules-unit-testing/index.ts b/packages/rules-unit-testing/index.ts index cfa91149eaa..a8bd500634d 100644 --- a/packages/rules-unit-testing/index.ts +++ b/packages/rules-unit-testing/index.ts @@ -33,6 +33,7 @@ export { initializeTestApp, loadDatabaseRules, loadFirestoreRules, + loadStorageRules, useEmulators, withFunctionTriggersDisabled } from './src/api'; diff --git a/packages/rules-unit-testing/package.json b/packages/rules-unit-testing/package.json index 8e5d9aee504..b51ce7b5c2c 100644 --- a/packages/rules-unit-testing/package.json +++ b/packages/rules-unit-testing/package.json @@ -15,7 +15,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 --project=foo --debug emulators:exec 'yarn test:nyc'", + "test": "FIREBASE_CLI_PREVIEWS=storageemulator STORAGE_EMULATOR_HOST=http://localhost:9199 firebase --project=foo --debug emulators:exec 'yarn test:nyc'", "test:ci": "node ../../scripts/run_tests_in_ci.js -s test" }, "license": "Apache-2.0", @@ -29,15 +29,15 @@ "devDependencies": { "@google-cloud/firestore": "4.8.1", "@types/request": "2.48.5", - "firebase-admin": "9.4.2", - "firebase-tools": "9.1.0", + "firebase-admin": "9.7.0", + "firebase-tools": "9.10.1", "firebase-functions": "3.13.0", "rollup": "2.35.1", "rollup-plugin-typescript2": "0.29.0" }, "peerDependencies": { "@google-cloud/firestore": "^4.2.0", - "firebase-admin": "^9.0.0" + "firebase-admin": "^9.7.0" }, "repository": { "directory": "packages/rules-unit-testing", diff --git a/packages/rules-unit-testing/src/api/index.ts b/packages/rules-unit-testing/src/api/index.ts index 818505f5c0a..ed052e5ede7 100644 --- a/packages/rules-unit-testing/src/api/index.ts +++ b/packages/rules-unit-testing/src/api/index.ts @@ -16,6 +16,11 @@ */ 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'; @@ -23,19 +28,25 @@ import { base64 } from '@firebase/util'; import { setLogLevel, LogLevel } from '@firebase/logger'; import { Component, ComponentType } from '@firebase/component'; -const { firestore, database } = firebase; -export { firestore, database }; +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 any of environment variable is set, use it for the Firestore emulator. */ +/** 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 */ @@ -47,6 +58,9 @@ 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; @@ -133,6 +147,10 @@ export type FirebaseEmulatorOptions = { host: string; port: number; }; + storage?: { + host: string; + port: number; + }; hub?: { host: string; port: number; @@ -193,6 +211,7 @@ export function apps(): firebase.app.App[] { export type AppOptions = { databaseName?: string; projectId?: string; + storageBucket?: string; auth?: TokenOptions; }; /** Construct an App authenticated with options.auth. */ @@ -201,19 +220,29 @@ export function initializeTestApp(options: AppOptions): firebase.app.App { ? createUnsecuredJwt(options.auth, options.projectId) : undefined; - return initializeApp(jwt, options.databaseName, options.projectId); + 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): firebase.app.App { +export function initializeAdminApp(options: AdminAppOptions): app.App { const admin = require('firebase-admin'); - const app = admin.initializeApp( - getAppOptions(options.databaseName, options.projectId), + const app: app.App = admin.initializeApp( + getAppOptions( + options.databaseName, + options.projectId, + options.storageBucket + ), getRandomAppName() ); @@ -248,6 +277,10 @@ export function useEmulators(options: FirebaseEmulatorOptions): void { _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); } @@ -301,6 +334,13 @@ export async function discoverEmulators( }; } + if (data.storage) { + options.storage = { + host: data.storage.host, + port: data.storage.port + }; + } + if (data.hub) { options.hub = { host: data.hub.host, @@ -351,6 +391,27 @@ function getFirestoreHost() { 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]; @@ -367,34 +428,52 @@ function getHubHost() { 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 + projectId?: string, + storageBucket?: string ): { [key: string]: string } { let appOptions: { [key: string]: string } = {}; if (databaseName) { - appOptions[ - 'databaseURL' - ] = `http://${getDatabaseHost()}?ns=${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 + projectId?: string, + storageBucket?: string ): firebase.app.App { - const appOptions = getAppOptions(databaseName, projectId); + const appOptions = getAppOptions(databaseName, projectId, storageBucket); const app = firebase.initializeApp(appOptions, getRandomAppName()); if (accessToken) { const mockAuthComponent = new Component( @@ -417,6 +496,9 @@ function initializeApp( ); } 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. @@ -424,10 +506,12 @@ function initializeApp( app.database().goOnline(); } if (projectId) { - app.firestore().settings({ - host: getFirestoreHost(), - ssl: false - }); + 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 @@ -498,6 +582,34 @@ export async function loadFirestoreRules( } } +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; }; diff --git a/packages/rules-unit-testing/test/database.test.ts b/packages/rules-unit-testing/test/database.test.ts index 7117f1bc233..98df97332ca 100644 --- a/packages/rules-unit-testing/test/database.test.ts +++ b/packages/rules-unit-testing/test/database.test.ts @@ -165,6 +165,10 @@ describe('Testing Module Tests', function () { host: 'localhost', port: 9003 }, + storage: { + host: 'localhost', + port: 9199 + }, hub: { host: 'localhost', port: 4400 @@ -216,7 +220,24 @@ describe('Testing Module Tests', function () { }); }); - it('initializeAdminApp() has admin access', async function () { + it('initializeAdminApp() has admin access to RTDB', async function () { + await firebase.loadDatabaseRules({ + databaseName: 'foo', + rules: '{ "rules": {".read": false, ".write": false} }' + }); + + const app = firebase.initializeAdminApp({ + projectId: 'foo', + databaseName: 'foo', + storageBucket: 'foo' + }); + + await firebase.assertSucceeds( + app.database().ref().child('/foo/bar').set({ hello: 'world' }) + ); + }); + + it('initializeAdminApp() has admin access to Firestore', async function () { await firebase.loadFirestoreRules({ projectId: 'foo', rules: `service cloud.firestore { @@ -226,22 +247,43 @@ describe('Testing Module Tests', function () { }` }); - await firebase.loadDatabaseRules({ - databaseName: 'foo', - rules: '{ "rules": {".read": false, ".write": false} }' - }); - const app = firebase.initializeAdminApp({ projectId: 'foo', - databaseName: 'foo' + databaseName: 'foo', + storageBucket: 'foo' }); await firebase.assertSucceeds( app.firestore().doc('/foo/bar').set({ hello: 'world' }) ); - await firebase.assertSucceeds( - app.database().ref().child('/foo/bar').set({ hello: 'world' }) - ); + }); + + it('initializeAdminApp() has admin access to storage', async function () { + await firebase.loadStorageRules({ + rules: `rules_version = '2'; + service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read, write: if false; + } + } + }` + }); + + const app = firebase.initializeAdminApp({ + projectId: 'foo', + databaseName: 'foo', + storageBucket: 'foo' + }); + + // TODO: This test cannot be enabled without adding credentials to the test environment + // due to an underlying issue with firebase-admin storage. For now we will run it + // locally but not in CI. + if (process.env.CI !== "true") { + await firebase.assertSucceeds( + app.storage().bucket().file('/foo/bar.txt').save('Hello, World!') + ); + } }); it('initializeAdminApp() and initializeTestApp() work together', async function () { @@ -257,12 +299,14 @@ describe('Testing Module Tests', function () { const adminApp = firebase.initializeAdminApp({ projectId: 'foo', - databaseName: 'foo' + databaseName: 'foo', + storageBucket: 'foo' }); const testApp = firebase.initializeTestApp({ projectId: 'foo', - databaseName: 'foo' + databaseName: 'foo', + storageBucket: 'foo' }); // Admin app can write anywhere @@ -375,6 +419,30 @@ describe('Testing Module Tests', function () { }); }); + it('loadStorageRules() succeeds on valid input', async function () { + await firebase.loadStorageRules({ + rules: `rules_version = '2'; + service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read, write: if false; + } + } + }` + }); + }); + + it('loadStorageRules() fails on invalid input', async function () { + const p = firebase.loadStorageRules({ + rules: `rules_version = '2'; + service firebase.storage { + banana + }` + }); + + await expect(p).to.eventually.be.rejected; + }); + it('clearFirestoreData() succeeds on valid input', async function () { await firebase.clearFirestoreData({ projectId: 'foo' diff --git a/packages/rules-unit-testing/test/storage.rules b/packages/rules-unit-testing/test/storage.rules new file mode 100644 index 00000000000..2cb8b6ece92 --- /dev/null +++ b/packages/rules-unit-testing/test/storage.rules @@ -0,0 +1,7 @@ +service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read, write: if false; + } + } +}