Skip to content

Commit 7ff2f2b

Browse files
authored
Implement rest of RUTv2 features. (#5360)
* Implement loading rules and withFunctionTriggersDisabled. * Implement clearFirestore and storage. * Add missing await. * Add default bucketUrl. * Use alternative method to clear bucket. * Use default param (review feedback).
1 parent f46d655 commit 7ff2f2b

File tree

11 files changed

+447
-78
lines changed

11 files changed

+447
-78
lines changed

packages/rules-unit-testing/src/impl/discovery.ts

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import { EmulatorConfig, HostAndPort } from '../public_types';
1919
import nodeFetch from 'node-fetch';
20+
import { makeUrl, fixHostname } from './url';
2021

2122
/**
2223
* Use the Firebase Emulator hub to discover other running emulators.
@@ -79,20 +80,6 @@ export interface DiscoveredEmulators {
7980
hub?: HostAndPort;
8081
}
8182

82-
function makeUrl(hostAndPort: HostAndPort | string, path: string): URL {
83-
if (typeof hostAndPort === 'object') {
84-
const { host, port } = hostAndPort;
85-
if (host.includes(':')) {
86-
hostAndPort = `[${host}]:${port}`;
87-
} else {
88-
hostAndPort = `${host}:${port}`;
89-
}
90-
}
91-
const url = new URL(`http://${hostAndPort}/`);
92-
url.pathname = path;
93-
return url;
94-
}
95-
9683
/**
9784
* @private
9885
*/
@@ -169,21 +156,3 @@ function emulatorFromEnvVar(envVar: string): HostAndPort | undefined {
169156
}
170157
return { host, port };
171158
}
172-
173-
/**
174-
* Return a connectable hostname, replacing wildcard 0.0.0.0 or :: with loopback
175-
* addresses 127.0.0.1 / ::1 correspondingly. See below for why this is needed:
176-
* https://github.com/firebase/firebase-tools-ui/issues/286
177-
*
178-
* This assumes emulators are running on the same device as the Emulator UI
179-
* server, which should hold if both are started from the same CLI command.
180-
*/
181-
function fixHostname(host: string, fallbackHost?: string): string {
182-
host = host.replace('[', '').replace(']', ''); // Remove IPv6 brackets
183-
if (host === '0.0.0.0') {
184-
host = fallbackHost || '127.0.0.1';
185-
} else if (host === '::') {
186-
host = fallbackHost || '::1';
187-
}
188-
return host;
189-
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @license
3+
* Copyright 2021 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { HostAndPort } from '../public_types';
19+
import { makeUrl } from './url';
20+
import fetch from 'node-fetch';
21+
22+
/**
23+
* @private
24+
*/
25+
export async function loadDatabaseRules(
26+
hostAndPort: HostAndPort,
27+
databaseName: string,
28+
rules: string
29+
): Promise<void> {
30+
const url = makeUrl(hostAndPort, '/.settings/rules.json');
31+
url.searchParams.append('ns', databaseName);
32+
const resp = await fetch(url, {
33+
method: 'PUT',
34+
headers: { Authorization: 'Bearer owner' },
35+
body: rules
36+
});
37+
38+
if (!resp.ok) {
39+
throw new Error(await resp.text());
40+
}
41+
}
42+
43+
/**
44+
* @private
45+
*/
46+
export async function loadFirestoreRules(
47+
hostAndPort: HostAndPort,
48+
projectId: string,
49+
rules: string
50+
): Promise<void> {
51+
const resp = await fetch(
52+
makeUrl(hostAndPort, `/emulator/v1/projects/${projectId}:securityRules`),
53+
{
54+
method: 'PUT',
55+
body: JSON.stringify({
56+
rules: {
57+
files: [{ content: rules }]
58+
}
59+
})
60+
}
61+
);
62+
63+
if (!resp.ok) {
64+
throw new Error(await resp.text());
65+
}
66+
}
67+
68+
/**
69+
* @private
70+
*/
71+
export async function loadStorageRules(
72+
hostAndPort: HostAndPort,
73+
rules: string
74+
): Promise<void> {
75+
const resp = await fetch(makeUrl(hostAndPort, '/internal/setRules'), {
76+
method: 'PUT',
77+
headers: {
78+
'Content-Type': 'application/json'
79+
},
80+
body: JSON.stringify({
81+
rules: {
82+
files: [{ name: 'storage.rules', content: rules }]
83+
}
84+
})
85+
});
86+
if (!resp.ok) {
87+
throw new Error(await resp.text());
88+
}
89+
}

packages/rules-unit-testing/src/impl/test_environment.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* limitations under the License.
1616
*/
1717

18+
import fetch from 'node-fetch';
1819
import firebase from 'firebase/compat/app';
1920
import 'firebase/firestore/compat';
2021
import 'firebase/database/compat';
@@ -28,6 +29,7 @@ import {
2829
} from '../public_types';
2930

3031
import { DiscoveredEmulators } from './discovery';
32+
import { makeUrl } from './url';
3133

3234
/**
3335
* An implementation of {@code RulesTestEnvironment}. This is private to hide the constructor,
@@ -100,14 +102,35 @@ export class RulesTestEnvironmentImpl implements RulesTestEnvironment {
100102
});
101103
}
102104

103-
clearFirestore(): Promise<void> {
105+
async clearFirestore(): Promise<void> {
104106
this.checkNotDestroyed();
105-
throw new Error('Method not implemented.');
107+
assertEmulatorRunning(this.emulators, 'firestore');
108+
109+
const resp = await fetch(
110+
makeUrl(
111+
this.emulators.firestore,
112+
`/emulator/v1/projects/${this.projectId}/databases/(default)/documents`
113+
),
114+
{
115+
method: 'DELETE'
116+
}
117+
);
118+
119+
if (!resp.ok) {
120+
throw new Error(await resp.text());
121+
}
106122
}
107123

108124
clearStorage(): Promise<void> {
109125
this.checkNotDestroyed();
110-
throw new Error('Method not implemented.');
126+
return this.withSecurityRulesDisabled(async context => {
127+
const { items } = await context.storage().ref().listAll();
128+
await Promise.all(
129+
items.map(item => {
130+
return item.delete();
131+
})
132+
);
133+
});
111134
}
112135

113136
async cleanup(): Promise<void> {
@@ -177,7 +200,7 @@ class RulesTestContextImpl implements RulesTestContext {
177200
);
178201
return database;
179202
}
180-
storage(bucketUrl?: string): firebase.storage.Storage {
203+
storage(bucketUrl = `gs://${this.projectId}`): firebase.storage.Storage {
181204
assertEmulatorRunning(this.emulators, 'storage');
182205
const storage = this.getApp().storage(bucketUrl);
183206
storage.useEmulator(
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @license
3+
* Copyright 2021 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { HostAndPort } from '../public_types';
19+
20+
/**
21+
* Return a connectable hostname, replacing wildcard 0.0.0.0 or :: with loopback
22+
* addresses 127.0.0.1 / ::1 correspondingly. See below for why this is needed:
23+
* https://github.com/firebase/firebase-tools-ui/issues/286
24+
*
25+
* This assumes emulators are running on the same device as fallbackHost (e.g.
26+
* hub), which should hold if both are started from the same CLI command.
27+
* @private
28+
*/
29+
export function fixHostname(host: string, fallbackHost?: string): string {
30+
host = host.replace('[', '').replace(']', ''); // Remove IPv6 brackets
31+
if (host === '0.0.0.0') {
32+
host = fallbackHost || '127.0.0.1';
33+
} else if (host === '::') {
34+
host = fallbackHost || '::1';
35+
}
36+
return host;
37+
}
38+
39+
/**
40+
* Create a URL with host, port, and path. Handles IPv6 bracketing correctly.
41+
* @private
42+
*/
43+
export function makeUrl(hostAndPort: HostAndPort | string, path: string): URL {
44+
if (typeof hostAndPort === 'object') {
45+
const { host, port } = hostAndPort;
46+
if (host.includes(':')) {
47+
hostAndPort = `[${host}]:${port}`;
48+
} else {
49+
hostAndPort = `${host}:${port}`;
50+
}
51+
}
52+
const url = new URL(`http://${hostAndPort}/`);
53+
url.pathname = path;
54+
return url;
55+
}

packages/rules-unit-testing/src/initialize.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@ import {
2121
discoverEmulators,
2222
getEmulatorHostAndPort
2323
} from './impl/discovery';
24-
import { RulesTestEnvironmentImpl } from './impl/test_environment';
24+
import {
25+
assertEmulatorRunning,
26+
RulesTestEnvironmentImpl
27+
} from './impl/test_environment';
28+
import {
29+
loadDatabaseRules,
30+
loadFirestoreRules,
31+
loadStorageRules
32+
} from './impl/rules';
2533

2634
/**
2735
* Initializes a test environment for rules unit testing. Call this function first for test setup.
@@ -73,7 +81,27 @@ export async function initializeTestEnvironment(
7381
}
7482
}
7583

76-
// TODO: Set security rules.
84+
if (config.database?.rules) {
85+
assertEmulatorRunning(emulators, 'database');
86+
await loadDatabaseRules(
87+
emulators.database,
88+
projectId,
89+
config.database.rules
90+
);
91+
}
92+
if (config.firestore?.rules) {
93+
assertEmulatorRunning(emulators, 'firestore');
94+
await loadFirestoreRules(
95+
emulators.firestore,
96+
projectId,
97+
config.firestore.rules
98+
);
99+
}
100+
if (config.storage?.rules) {
101+
assertEmulatorRunning(emulators, 'storage');
102+
await loadStorageRules(emulators.storage, config.storage.rules);
103+
}
104+
77105
return new RulesTestEnvironmentImpl(projectId, emulators);
78106
}
79107

packages/rules-unit-testing/src/util.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515
* limitations under the License.
1616
*/
1717

18+
import {
19+
EMULATOR_HOST_ENV_VARS,
20+
getEmulatorHostAndPort
21+
} from './impl/discovery';
22+
import { fixHostname, makeUrl } from './impl/url';
23+
import { HostAndPort } from './public_types';
24+
import fetch from 'node-fetch';
25+
1826
/**
1927
* Run a setup function with background Cloud Functions triggers disabled. This can be used to
2028
* import data into the Realtime Database or Cloud Firestore emulator without triggering locally
@@ -41,7 +49,8 @@ export async function withFunctionTriggersDisabled<TResult>(
4149
* @param fn an function which may be sync or async (returns a promise)
4250
* @param hub the host and port of the Emulator Hub (ex: `{host: 'localhost', port: 4400}`)
4351
* @public
44-
*/ export async function withFunctionTriggersDisabled<TResult>(
52+
*/
53+
export async function withFunctionTriggersDisabled<TResult>(
4554
hub: { host: string; port: number },
4655
fn: () => TResult | Promise<TResult>
4756
): Promise<TResult>;
@@ -50,7 +59,60 @@ export async function withFunctionTriggersDisabled<TResult>(
5059
fnOrHub: { host: string; port: number } | (() => TResult | Promise<TResult>),
5160
maybeFn?: () => TResult | Promise<TResult>
5261
): Promise<TResult> {
53-
throw new Error('unimplemented');
62+
let hub: HostAndPort | undefined;
63+
if (typeof fnOrHub === 'function') {
64+
maybeFn = fnOrHub;
65+
hub = getEmulatorHostAndPort('hub');
66+
} else {
67+
hub = getEmulatorHostAndPort('hub', fnOrHub);
68+
if (!maybeFn) {
69+
throw new Error('The callback function must be specified!');
70+
}
71+
}
72+
if (!hub) {
73+
throw new Error(
74+
'Please specify the Emulator Hub host and port via arguments or set the environment ' +
75+
`varible ${EMULATOR_HOST_ENV_VARS.hub}!`
76+
);
77+
}
78+
79+
hub.host = fixHostname(hub.host);
80+
makeUrl(hub, '/functions/disableBackgroundTriggers');
81+
// Disable background triggers
82+
const disableRes = await fetch(
83+
makeUrl(hub, '/functions/disableBackgroundTriggers'),
84+
{
85+
method: 'PUT'
86+
}
87+
);
88+
if (!disableRes.ok) {
89+
throw new Error(
90+
`HTTP Error ${disableRes.status} when disabling functions triggers, are you using firebase-tools 8.13.0 or higher?`
91+
);
92+
}
93+
94+
// Run the user's function
95+
let result: TResult | undefined = undefined;
96+
try {
97+
result = await maybeFn();
98+
} finally {
99+
// Re-enable background triggers
100+
const enableRes = await fetch(
101+
makeUrl(hub, '/functions/enableBackgroundTriggers'),
102+
{
103+
method: 'PUT'
104+
}
105+
);
106+
107+
if (!enableRes.ok) {
108+
throw new Error(
109+
`HTTP Error ${enableRes.status} when enabling functions triggers, are you using firebase-tools 8.13.0 or higher?`
110+
);
111+
}
112+
}
113+
114+
// Return the user's function result
115+
return result;
54116
}
55117

56118
/**

0 commit comments

Comments
 (0)