Skip to content

Implement rest of RUTv2 features. #5360

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 6 commits into from
Aug 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 1 addition & 32 deletions packages/rules-unit-testing/src/impl/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import { EmulatorConfig, HostAndPort } from '../public_types';
import nodeFetch from 'node-fetch';
import { makeUrl, fixHostname } from './url';

/**
* Use the Firebase Emulator hub to discover other running emulators.
Expand Down Expand Up @@ -79,20 +80,6 @@ export interface DiscoveredEmulators {
hub?: HostAndPort;
}

function makeUrl(hostAndPort: HostAndPort | string, path: string): URL {
if (typeof hostAndPort === 'object') {
const { host, port } = hostAndPort;
if (host.includes(':')) {
hostAndPort = `[${host}]:${port}`;
} else {
hostAndPort = `${host}:${port}`;
}
}
const url = new URL(`http://${hostAndPort}/`);
url.pathname = path;
return url;
}

/**
* @private
*/
Expand Down Expand Up @@ -169,21 +156,3 @@ function emulatorFromEnvVar(envVar: string): HostAndPort | undefined {
}
return { host, port };
}

/**
* Return a connectable hostname, replacing wildcard 0.0.0.0 or :: with loopback
* addresses 127.0.0.1 / ::1 correspondingly. See below for why this is needed:
* https://github.com/firebase/firebase-tools-ui/issues/286
*
* This assumes emulators are running on the same device as the Emulator UI
* server, which should hold if both are started from the same CLI command.
*/
function fixHostname(host: string, fallbackHost?: string): string {
host = host.replace('[', '').replace(']', ''); // Remove IPv6 brackets
if (host === '0.0.0.0') {
host = fallbackHost || '127.0.0.1';
} else if (host === '::') {
host = fallbackHost || '::1';
}
return host;
}
89 changes: 89 additions & 0 deletions packages/rules-unit-testing/src/impl/rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* @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 { HostAndPort } from '../public_types';
import { makeUrl } from './url';
import fetch from 'node-fetch';

/**
* @private
*/
export async function loadDatabaseRules(
hostAndPort: HostAndPort,
databaseName: string,
rules: string
): Promise<void> {
const url = makeUrl(hostAndPort, '/.settings/rules.json');
url.searchParams.append('ns', databaseName);
const resp = await fetch(url, {
method: 'PUT',
headers: { Authorization: 'Bearer owner' },
body: rules
});

if (!resp.ok) {
throw new Error(await resp.text());
}
}

/**
* @private
*/
export async function loadFirestoreRules(
hostAndPort: HostAndPort,
projectId: string,
rules: string
): Promise<void> {
const resp = await fetch(
makeUrl(hostAndPort, `/emulator/v1/projects/${projectId}:securityRules`),
{
method: 'PUT',
body: JSON.stringify({
rules: {
files: [{ content: rules }]
}
})
}
);

if (!resp.ok) {
throw new Error(await resp.text());
}
}

/**
* @private
*/
export async function loadStorageRules(
hostAndPort: HostAndPort,
rules: string
): Promise<void> {
const resp = await fetch(makeUrl(hostAndPort, '/internal/setRules'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
rules: {
files: [{ name: 'storage.rules', content: rules }]
}
})
});
if (!resp.ok) {
throw new Error(await resp.text());
}
}
31 changes: 27 additions & 4 deletions packages/rules-unit-testing/src/impl/test_environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* limitations under the License.
*/

import fetch from 'node-fetch';
import firebase from 'firebase/compat/app';
import 'firebase/firestore/compat';
import 'firebase/database/compat';
Expand All @@ -28,6 +29,7 @@ import {
} from '../public_types';

import { DiscoveredEmulators } from './discovery';
import { makeUrl } from './url';

/**
* An implementation of {@code RulesTestEnvironment}. This is private to hide the constructor,
Expand Down Expand Up @@ -100,14 +102,35 @@ export class RulesTestEnvironmentImpl implements RulesTestEnvironment {
});
}

clearFirestore(): Promise<void> {
async clearFirestore(): Promise<void> {
this.checkNotDestroyed();
throw new Error('Method not implemented.');
assertEmulatorRunning(this.emulators, 'firestore');

const resp = await fetch(
makeUrl(
this.emulators.firestore,
`/emulator/v1/projects/${this.projectId}/databases/(default)/documents`
),
{
method: 'DELETE'
}
);

if (!resp.ok) {
throw new Error(await resp.text());
}
}

clearStorage(): Promise<void> {
this.checkNotDestroyed();
throw new Error('Method not implemented.');
return this.withSecurityRulesDisabled(async context => {
const { items } = await context.storage().ref().listAll();
await Promise.all(
items.map(item => {
return item.delete();
})
);
});
}

async cleanup(): Promise<void> {
Expand Down Expand Up @@ -177,7 +200,7 @@ class RulesTestContextImpl implements RulesTestContext {
);
return database;
}
storage(bucketUrl?: string): firebase.storage.Storage {
storage(bucketUrl = `gs://${this.projectId}`): firebase.storage.Storage {
assertEmulatorRunning(this.emulators, 'storage');
const storage = this.getApp().storage(bucketUrl);
storage.useEmulator(
Expand Down
55 changes: 55 additions & 0 deletions packages/rules-unit-testing/src/impl/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @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 { HostAndPort } from '../public_types';

/**
* Return a connectable hostname, replacing wildcard 0.0.0.0 or :: with loopback
* addresses 127.0.0.1 / ::1 correspondingly. See below for why this is needed:
* https://github.com/firebase/firebase-tools-ui/issues/286
*
* This assumes emulators are running on the same device as fallbackHost (e.g.
* hub), which should hold if both are started from the same CLI command.
* @private
*/
export function fixHostname(host: string, fallbackHost?: string): string {
host = host.replace('[', '').replace(']', ''); // Remove IPv6 brackets
if (host === '0.0.0.0') {
host = fallbackHost || '127.0.0.1';
} else if (host === '::') {
host = fallbackHost || '::1';
}
return host;
}

/**
* Create a URL with host, port, and path. Handles IPv6 bracketing correctly.
* @private
*/
export function makeUrl(hostAndPort: HostAndPort | string, path: string): URL {
if (typeof hostAndPort === 'object') {
const { host, port } = hostAndPort;
if (host.includes(':')) {
hostAndPort = `[${host}]:${port}`;
} else {
hostAndPort = `${host}:${port}`;
}
}
const url = new URL(`http://${hostAndPort}/`);
url.pathname = path;
return url;
}
32 changes: 30 additions & 2 deletions packages/rules-unit-testing/src/initialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ import {
discoverEmulators,
getEmulatorHostAndPort
} from './impl/discovery';
import { RulesTestEnvironmentImpl } from './impl/test_environment';
import {
assertEmulatorRunning,
RulesTestEnvironmentImpl
} from './impl/test_environment';
import {
loadDatabaseRules,
loadFirestoreRules,
loadStorageRules
} from './impl/rules';

/**
* Initializes a test environment for rules unit testing. Call this function first for test setup.
Expand Down Expand Up @@ -73,7 +81,27 @@ export async function initializeTestEnvironment(
}
}

// TODO: Set security rules.
if (config.database?.rules) {
assertEmulatorRunning(emulators, 'database');
await loadDatabaseRules(
emulators.database,
projectId,
config.database.rules
);
}
if (config.firestore?.rules) {
assertEmulatorRunning(emulators, 'firestore');
await loadFirestoreRules(
emulators.firestore,
projectId,
config.firestore.rules
);
}
if (config.storage?.rules) {
assertEmulatorRunning(emulators, 'storage');
await loadStorageRules(emulators.storage, config.storage.rules);
}

return new RulesTestEnvironmentImpl(projectId, emulators);
}

Expand Down
66 changes: 64 additions & 2 deletions packages/rules-unit-testing/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
* limitations under the License.
*/

import {
EMULATOR_HOST_ENV_VARS,
getEmulatorHostAndPort
} from './impl/discovery';
import { fixHostname, makeUrl } from './impl/url';
import { HostAndPort } from './public_types';
import fetch from 'node-fetch';

/**
* 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
Expand All @@ -41,7 +49,8 @@ export async function withFunctionTriggersDisabled<TResult>(
* @param fn an function which may be sync or async (returns a promise)
* @param hub the host and port of the Emulator Hub (ex: `{host: 'localhost', port: 4400}`)
* @public
*/ export async function withFunctionTriggersDisabled<TResult>(
*/
export async function withFunctionTriggersDisabled<TResult>(
hub: { host: string; port: number },
fn: () => TResult | Promise<TResult>
): Promise<TResult>;
Expand All @@ -50,7 +59,60 @@ export async function withFunctionTriggersDisabled<TResult>(
fnOrHub: { host: string; port: number } | (() => TResult | Promise<TResult>),
maybeFn?: () => TResult | Promise<TResult>
): Promise<TResult> {
throw new Error('unimplemented');
let hub: HostAndPort | undefined;
if (typeof fnOrHub === 'function') {
maybeFn = fnOrHub;
hub = getEmulatorHostAndPort('hub');
} else {
hub = getEmulatorHostAndPort('hub', fnOrHub);
if (!maybeFn) {
throw new Error('The callback function must be specified!');
}
}
if (!hub) {
throw new Error(
'Please specify the Emulator Hub host and port via arguments or set the environment ' +
`varible ${EMULATOR_HOST_ENV_VARS.hub}!`
);
}

hub.host = fixHostname(hub.host);
makeUrl(hub, '/functions/disableBackgroundTriggers');
// Disable background triggers
const disableRes = await fetch(
makeUrl(hub, '/functions/disableBackgroundTriggers'),
{
method: 'PUT'
}
);
if (!disableRes.ok) {
throw new Error(
`HTTP Error ${disableRes.status} 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 maybeFn();
} finally {
// Re-enable background triggers
const enableRes = await fetch(
makeUrl(hub, '/functions/enableBackgroundTriggers'),
{
method: 'PUT'
}
);

if (!enableRes.ok) {
throw new Error(
`HTTP Error ${enableRes.status} when enabling functions triggers, are you using firebase-tools 8.13.0 or higher?`
);
}
}

// Return the user's function result
return result;
}

/**
Expand Down
Loading