Skip to content

Commit e913201

Browse files
committed
Implement most of RUTv2 features.
1 parent 64a673a commit e913201

File tree

4 files changed

+445
-3
lines changed

4 files changed

+445
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
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 { createMockUserToken } from '@firebase/util';
19+
20+
// TODO: Change these imports to firebase/* once exp packages are moved into their places.
21+
import {
22+
connectDatabaseEmulator,
23+
Database,
24+
set,
25+
ref
26+
} from '@firebase/database/dist/exp';
27+
import {
28+
FirestoreSettings,
29+
Firestore,
30+
connectFirestoreEmulator
31+
} from '@firebase/firestore/dist/exp';
32+
import { connectStorageEmulator, FirebaseStorage } from '@firebase/storage/exp';
33+
34+
import { FirebaseApp } from '@firebase/app-types';
35+
import {
36+
HostAndPort,
37+
RulesTestContext,
38+
RulesTestEnvironment,
39+
TokenOptions
40+
} from '../public_types';
41+
import firebase from '@firebase/app-compat';
42+
import '@firebase/firestore/dist/compat/esm2017/firestore/index';
43+
import '@firebase/database/dist/compat/esm2017/index';
44+
import '@firebase/storage/dist/compat/esm2017/index';
45+
46+
import { DiscoveredEmulators } from './discovery';
47+
48+
/**
49+
* An implementation of {@code RulesTestEnvironment}. This is private to hide the constructor,
50+
* which should never be directly called by the developer.
51+
* @private
52+
*/
53+
export class RulesTestEnvironmentImpl implements RulesTestEnvironment {
54+
private contexts = new Set<RulesTestContextImpl>();
55+
private destroyed = false;
56+
57+
constructor(
58+
readonly projectId: string,
59+
readonly emulators: DiscoveredEmulators
60+
) {}
61+
62+
authenticatedContext(
63+
user_id: string,
64+
tokenOptions?: TokenOptions
65+
): RulesTestContext {
66+
this.checkNotDestroyed();
67+
const token = createMockUserToken(
68+
{
69+
...tokenOptions,
70+
sub: user_id,
71+
user_id: user_id
72+
},
73+
this.projectId
74+
);
75+
return this.createContext(token);
76+
}
77+
78+
unauthenticatedContext(): RulesTestContext {
79+
this.checkNotDestroyed();
80+
return this.createContext(/* authToken = */ undefined);
81+
}
82+
83+
async withSecurityRulesDisabled(
84+
callback: (context: RulesTestContext) => Promise<void>
85+
): Promise<void> {
86+
this.checkNotDestroyed();
87+
// The "owner" token is recognized by the emulators as a special value that bypasses Security
88+
// Rules. This should only ever be used in withSecurityRulesDisabled.
89+
// If you're reading this and thinking about doing this in your own app / tests / scripts, think
90+
// twice. Instead, just use withSecurityRulesDisabled for unit testing OR connect your Firebase
91+
// Admin SDKs to the emulators for integration testing via environment variables.
92+
// See: https://firebase.google.com/docs/emulator-suite/connect_firestore#admin_sdks
93+
const context = this.createContext('owner');
94+
try {
95+
await callback(context);
96+
} finally {
97+
// We eagarly clean up this context to actively prevent misuse outside of the callback, e.g.
98+
// storing the context in a variable.
99+
context.cleanup();
100+
this.contexts.delete(context);
101+
}
102+
}
103+
104+
private createContext(authToken: string | undefined): RulesTestContextImpl {
105+
const context = new RulesTestContextImpl(
106+
this.projectId,
107+
this.emulators,
108+
authToken
109+
);
110+
this.contexts.add(context);
111+
return context;
112+
}
113+
114+
clearDatabase(): Promise<void> {
115+
this.checkNotDestroyed();
116+
return this.withSecurityRulesDisabled(context => {
117+
return set(ref(context.database(), '/'), null);
118+
});
119+
}
120+
121+
clearFirestore(): Promise<void> {
122+
this.checkNotDestroyed();
123+
throw new Error('Method not implemented.');
124+
}
125+
126+
clearStorage(): Promise<void> {
127+
this.checkNotDestroyed();
128+
throw new Error('Method not implemented.');
129+
}
130+
131+
async cleanup(): Promise<void> {
132+
this.destroyed = true;
133+
this.contexts.forEach(context => {
134+
context.envDestroyed = true;
135+
context.cleanup();
136+
});
137+
this.contexts.clear();
138+
}
139+
140+
private checkNotDestroyed() {
141+
if (this.destroyed) {
142+
throw new Error(
143+
'This RulesTestEnvironment has already been cleaned up. ' +
144+
'(This may indicate a leak or missing `await` in your test cases. If you do intend to ' +
145+
'perform more tests, please call cleanup() later or create another RulesTestEnvironment.)'
146+
);
147+
}
148+
}
149+
}
150+
/**
151+
* An implementation of {@code RulesTestContext}. This is private to hide the constructor,
152+
* which should never be directly called by the developer.
153+
* @private
154+
*/
155+
class RulesTestContextImpl implements RulesTestContext {
156+
private app?: FirebaseApp;
157+
private destroyed = false;
158+
envDestroyed = false;
159+
160+
constructor(
161+
readonly projectId: string,
162+
readonly emulators: DiscoveredEmulators,
163+
readonly authToken: string | undefined
164+
) {}
165+
166+
cleanup() {
167+
this.destroyed = true;
168+
this.app?.delete();
169+
170+
this.app = undefined;
171+
}
172+
173+
firestore(settings?: FirestoreSettings): Firestore {
174+
assertEmulatorRunning(this.emulators, 'firestore');
175+
const firestoreCompat = this.getApp().firestore!();
176+
if (settings) {
177+
firestoreCompat.settings(settings);
178+
}
179+
const firestore = firestoreCompat as unknown as Firestore;
180+
connectFirestoreEmulator(
181+
firestore,
182+
this.emulators.firestore.host,
183+
this.emulators.firestore.port,
184+
{ mockUserToken: this.authToken }
185+
);
186+
return firestore;
187+
}
188+
database(databaseURL?: string): Database {
189+
assertEmulatorRunning(this.emulators, 'database');
190+
const database = this.getApp().database!(
191+
databaseURL
192+
) as unknown as Database;
193+
connectDatabaseEmulator(
194+
database,
195+
this.emulators.database.host,
196+
this.emulators.database.port,
197+
{ mockUserToken: this.authToken }
198+
);
199+
return database;
200+
}
201+
storage(bucketUrl?: string): FirebaseStorage {
202+
assertEmulatorRunning(this.emulators, 'storage');
203+
const storage = this.getApp().storage!(
204+
bucketUrl
205+
) as unknown as FirebaseStorage;
206+
connectStorageEmulator(
207+
storage,
208+
this.emulators.storage.host,
209+
this.emulators.storage.port,
210+
{ mockUserToken: this.authToken }
211+
);
212+
return storage;
213+
}
214+
215+
private getApp(): FirebaseApp {
216+
if (this.envDestroyed) {
217+
throw new Error(
218+
'This RulesTestContext is no longer valid because its RulesTestEnvironment has been ' +
219+
'cleaned up. (This may indicate a leak or missing `await` in your test cases.)'
220+
);
221+
}
222+
if (this.destroyed) {
223+
throw new Error(
224+
'This RulesTestContext is no longer valid. When using withSecurityRulesDisabled, ' +
225+
'make sure to perform all operations on the context within the callback function and ' +
226+
'return a Promise that resolves when the operations are done.'
227+
);
228+
}
229+
if (!this.app) {
230+
this.app = firebase.initializeApp(
231+
{ projectId: this.projectId },
232+
`_Firebase_RulesUnitTesting_${Date.now()}_${Math.random()}`
233+
);
234+
}
235+
return this.app;
236+
}
237+
}
238+
239+
export function assertEmulatorRunning<E extends keyof DiscoveredEmulators>(
240+
emulators: DiscoveredEmulators,
241+
emulator: E
242+
): asserts emulators is Record<E, HostAndPort> {
243+
if (!emulators[emulator]) {
244+
if (emulators.hub) {
245+
throw new Error(
246+
`The ${emulator} emulator is not running (according to Emulator hub). To force ` +
247+
'connecting anyway, please specify its host and port in initializeTestEnvironment({...}).'
248+
);
249+
} else {
250+
throw new Error(
251+
`The host and port of the ${emulator} emulator must be specified. (You may wrap the test ` +
252+
"script with `firebase emulators:exec './your-test-script'` to enable automatic " +
253+
`discovery, or specify manually via initializeTestEnvironment({${emulator}: {host, port}}).`
254+
);
255+
}
256+
}
257+
}

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

+3-2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
discoverEmulators,
2222
getEmulatorHostAndPort
2323
} from './impl/discovery';
24+
import { RulesTestEnvironmentImpl } from './impl/test_environment';
2425

2526
/**
2627
* Initializes a test environment for rules unit testing. Call this function first for test setup.
@@ -72,8 +73,8 @@ export async function initializeTestEnvironment(
7273
}
7374
}
7475

75-
// TODO: Create test env and set security rules.
76-
throw new Error('unimplemented');
76+
// TODO: Set security rules.
77+
return new RulesTestEnvironmentImpl(projectId, emulators);
7778
}
7879

7980
const SUPPORTED_EMULATORS = ['database', 'firestore', 'storage'] as const;

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

+28-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,34 @@ export async function withFunctionTriggersDisabled<TResult>(
7070
* ```
7171
*/
7272
export function assertFails(pr: Promise<any>): Promise<any> {
73-
throw new Error('unimplemented');
73+
return pr.then(
74+
() => {
75+
return Promise.reject(
76+
new Error('Expected request to fail, but it succeeded.')
77+
);
78+
},
79+
(err: any) => {
80+
const errCode = (err && err.code && err.code.toLowerCase()) || '';
81+
const errMessage =
82+
(err && err.message && err.message.toLowerCase()) || '';
83+
const isPermissionDenied =
84+
errCode === 'permission-denied' ||
85+
errCode === 'permission_denied' ||
86+
errMessage.indexOf('permission_denied') >= 0 ||
87+
errMessage.indexOf('permission denied') >= 0 ||
88+
// Storage permission errors contain message: (storage/unauthorized)
89+
errMessage.indexOf('unauthorized') >= 0;
90+
91+
if (!isPermissionDenied) {
92+
return Promise.reject(
93+
new Error(
94+
`Expected PERMISSION_DENIED but got unexpected error: ${err}`
95+
)
96+
);
97+
}
98+
return err;
99+
}
100+
);
74101
}
75102

76103
/**

0 commit comments

Comments
 (0)