Skip to content

Commit

Permalink
Add mockUserToken support for Firestore Emulator. (#4837)
Browse files Browse the repository at this point in the history
* Create sweet-monkeys-warn.md

* Add mockUserToken support for Firestore.

* Create lemon-ligers-protect.md

* Fix test for credentials.

* Swap validation order.

* Bump firebase by minor.

* Reuse types from @firebase/util.

* Fix import lint.

* Update .changeset/lemon-ligers-protect.md

Co-authored-by: Feiyang <feiyangc@google.com>

* Delete sweet-monkeys-warn.md

* Add signature change to firebase/index.d.ts.

* Format comments.

* Add emulator utils to Firestore externs.

* Copy typings into firebase/index.d.ts.

* Use jsdoc style comments.

Co-authored-by: Sebastian Schmidt <mrschmidt@google.com>
Co-authored-by: Feiyang <feiyangc@google.com>
  • Loading branch information
3 people committed May 3, 2021
1 parent e123f24 commit 97f61e6
Show file tree
Hide file tree
Showing 10 changed files with 211 additions and 10 deletions.
7 changes: 7 additions & 0 deletions .changeset/lemon-ligers-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@firebase/firestore-types': minor
'@firebase/firestore': minor
'firebase': minor
---

Add mockUserToken support for Firestore.
85 changes: 84 additions & 1 deletion packages/firebase/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8179,8 +8179,16 @@ declare namespace firebase.firestore {
*
* @param host the emulator host (ex: localhost).
* @param port the emulator port (ex: 9000).
* @param options.mockUserToken - the mock auth token to use for unit
* testing Security Rules.
*/
useEmulator(host: string, port: number): void;
useEmulator(
host: string,
port: number,
options?: {
mockUserToken?: EmulatorMockTokenOptions;
}
): void;

/**
* Attempts to enable persistent storage, if possible.
Expand Down Expand Up @@ -9976,6 +9984,81 @@ declare namespace firebase.firestore {
name: string;
stack?: string;
}

type FirebaseSignInProvider =
| 'custom'
| 'email'
| 'password'
| 'phone'
| 'anonymous'
| 'google.com'
| 'facebook.com'
| 'github.com'
| 'twitter.com'
| 'microsoft.com'
| 'apple.com';

interface 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: FirebaseSignInProvider;

/** A map of providers to the user's list of unique identifiers from each provider */
identities?: { [provider in FirebaseSignInProvider]?: string[] };
};

/** Custom claims set by the developer */
[claim: string]: unknown;

// NO LONGER SUPPORTED. Use "sub" instead. (Not a jsdoc comment to avoid generating docs.)
uid?: never;
}

export type EmulatorMockTokenOptions = (
| { user_id: string }
| { sub: string }
) &
Partial<FirebaseIdToken>;
}

export default firebase;
Expand Down
10 changes: 8 additions & 2 deletions packages/firestore-types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* limitations under the License.
*/

import { FirebaseApp, FirebaseNamespace } from '@firebase/app-types';
import { EmulatorMockTokenOptions } from '@firebase/util';

export type DocumentData = { [field: string]: any };

Expand Down Expand Up @@ -61,7 +61,13 @@ export class FirebaseFirestore {

settings(settings: Settings): void;

useEmulator(host: string, port: number): void;
useEmulator(
host: string,
port: number,
options?: {
mockUserToken?: EmulatorMockTokenOptions;
}
): void;

enablePersistence(settings?: PersistenceSettings): Promise<void>;

Expand Down
3 changes: 2 additions & 1 deletion packages/firestore-types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"index.d.ts"
],
"peerDependencies": {
"@firebase/app-types": "0.x"
"@firebase/app-types": "0.x",
"@firebase/util": "1.x"
},
"repository": {
"directory": "packages/firestore-types",
Expand Down
3 changes: 2 additions & 1 deletion packages/firestore/externs.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"externs" : [
"externs": [
"node_modules/@types/node/base.d.ts",
"node_modules/@types/node/globals.d.ts",
"node_modules/typescript/lib/lib.es5.d.ts",
Expand All @@ -26,6 +26,7 @@
"packages/logger/dist/src/logger.d.ts",
"packages/webchannel-wrapper/src/index.d.ts",
"packages/util/dist/src/crypt.d.ts",
"packages/util/dist/src/emulator.d.ts",
"packages/util/dist/src/environment.d.ts",
"packages/util/dist/src/compat.d.ts",
"packages/firestore/export.ts",
Expand Down
35 changes: 35 additions & 0 deletions packages/firestore/src/api/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,41 @@ export class EmptyCredentialsProvider implements CredentialsProvider {
}
}

/**
* A CredentialsProvider that always returns a constant token. Used for
* emulator token mocking.
*/
export class EmulatorCredentialsProvider implements CredentialsProvider {
constructor(private token: Token) {}

/**
* Stores the listener registered with setChangeListener()
* This isn't actually necessary since the UID never changes, but we use this
* to verify the listen contract is adhered to in tests.
*/
private changeListener: CredentialChangeListener | null = null;

getToken(): Promise<Token | null> {
return Promise.resolve(this.token);
}

invalidateToken(): void {}

setChangeListener(changeListener: CredentialChangeListener): void {
debugAssert(
!this.changeListener,
'Can only call setChangeListener() once.'
);
this.changeListener = changeListener;
// Fire with initial user.
changeListener(this.token.user);
}

removeChangeListener(): void {
this.changeListener = null;
}
}

export class FirebaseCredentialsProvider implements CredentialsProvider {
/**
* The auth token listener registered with FirebaseApp, retained here so we
Expand Down
16 changes: 13 additions & 3 deletions packages/firestore/src/api/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ import {
WhereFilterOp as PublicWhereFilterOp,
WriteBatch as PublicWriteBatch
} from '@firebase/firestore-types';
import { Compat, getModularInstance } from '@firebase/util';
import {
Compat,
EmulatorMockTokenOptions,
getModularInstance
} from '@firebase/util';

import {
LoadBundleTask,
Expand Down Expand Up @@ -223,8 +227,14 @@ export class Firestore
this._delegate._setSettings(settingsLiteral);
}

useEmulator(host: string, port: number): void {
useFirestoreEmulator(this._delegate, host, port);
useEmulator(
host: string,
port: number,
options: {
mockUserToken?: EmulatorMockTokenOptions;
} = {}
): void {
useFirestoreEmulator(this._delegate, host, port, options);
}

enableNetwork(): Promise<void> {
Expand Down
30 changes: 28 additions & 2 deletions packages/firestore/src/lite/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ import {
} from '@firebase/app-exp';
import { FirebaseAuthInternalName } from '@firebase/auth-interop-types';
import { Provider } from '@firebase/component';
import { createMockUserToken, EmulatorMockTokenOptions } from '@firebase/util';

import {
CredentialsProvider,
EmptyCredentialsProvider,
EmulatorCredentialsProvider,
FirebaseCredentialsProvider,
makeCredentialsProvider
makeCredentialsProvider,
OAuthToken
} from '../api/credentials';
import { User } from '../auth/user';
import { DatabaseId } from '../core/database_info';
import { Code, FirestoreError } from '../util/error';
import { cast } from '../util/input_validation';
Expand Down Expand Up @@ -226,11 +230,16 @@ export function getFirestore(app: FirebaseApp = getApp()): FirebaseFirestore {
* emulator.
* @param host - the emulator host (ex: localhost).
* @param port - the emulator port (ex: 9000).
* @param options.mockUserToken - the mock auth token to use for unit testing
* Security Rules.
*/
export function useFirestoreEmulator(
firestore: FirebaseFirestore,
host: string,
port: number
port: number,
options: {
mockUserToken?: EmulatorMockTokenOptions;
} = {}
): void {
firestore = cast(firestore, FirebaseFirestore);
const settings = firestore._getSettings();
Expand All @@ -247,6 +256,23 @@ export function useFirestoreEmulator(
host: `${host}:${port}`,
ssl: false
});

if (options.mockUserToken) {
// Let createMockUserToken validate first (catches common mistakes like
// invalid field "uid" and missing field "sub" / "user_id".)
const token = createMockUserToken(options.mockUserToken);
const uid = options.mockUserToken.sub || options.mockUserToken.user_id;
if (!uid) {
throw new FirestoreError(
Code.INVALID_ARGUMENT,
"mockUserToken must contain 'sub' or 'user_id' field!"
);
}

firestore._credentials = new EmulatorCredentialsProvider(
new OAuthToken(token, new User(uid))
);
}
}

/**
Expand Down
18 changes: 18 additions & 0 deletions packages/firestore/test/integration/api/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,24 @@ apiDescribe('Validation:', (persistence: boolean) => {
expect(() => db.useEmulator('localhost', 9000)).to.throw(errorMsg);
}
);

validationIt(persistence, 'useEmulator can set mockUserToken', () => {
const db = newTestFirestore('test-project');
// Verify that this doesn't throw.
db.useEmulator('localhost', 9000, { mockUserToken: { sub: 'foo' } });
});

validationIt(
persistence,
'throws if sub / user_id is missing in mockUserToken',
async db => {
const errorMsg = "mockUserToken must contain 'sub' or 'user_id' field!";

expect(() =>
db.useEmulator('localhost', 9000, { mockUserToken: {} as any })
).to.throw(errorMsg);
}
);
});

describe('Firestore', () => {
Expand Down
14 changes: 14 additions & 0 deletions packages/firestore/test/unit/api/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import { expect } from 'chai';

import { EmulatorCredentialsProvider } from '../../../src/api/credentials';
import {
collectionReference,
documentReference,
Expand Down Expand Up @@ -250,4 +251,17 @@ describe('Settings', () => {
expect(db._delegate._getSettings().host).to.equal('localhost:9000');
expect(db._delegate._getSettings().ssl).to.be.false;
});

it('sets credentials based on mockUserToken', async () => {
// Use a new instance of Firestore in order to configure settings.
const db = newTestFirestore();
const mockUserToken = { sub: 'foobar' };
db.useEmulator('localhost', 9000, { mockUserToken });

const credentials = db._delegate._credentials;
expect(credentials).to.be.instanceOf(EmulatorCredentialsProvider);
const token = await credentials.getToken();
expect(token!.type).to.eql('OAuth');
expect(token!.user.uid).to.eql(mockUserToken.sub);
});
});

0 comments on commit 97f61e6

Please sign in to comment.