voluptasmollitia
Version:
Monorepo for the Firebase JavaScript SDK
735 lines (633 loc) • 19.9 kB
text/typescript
/**
* @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';
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<FirebaseIdToken> & { 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 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 [
base64.encodeString(JSON.stringify(header), /*webSafe=*/ false),
base64.encodeString(JSON.stringify(payload), /*webSafe=*/ false),
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<FirebaseEmulatorOptions> {
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<void> {
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<void> {
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<void> {
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<void> {
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<TResult>(
fn: () => TResult | Promise<TResult>
): Promise<TResult> {
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>): 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;
if (!isPermissionDenied) {
return Promise.reject(
new Error(
`Expected PERMISSION_DENIED but got unexpected error: ${err}`
)
);
}
return err;
}
);
}
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);
});
}