diff --git a/spec/index.spec.ts b/spec/index.spec.ts index 0c6fc3f..924af9c 100644 --- a/spec/index.spec.ts +++ b/spec/index.spec.ts @@ -62,6 +62,7 @@ describe('index', () => { import './lifecycle.spec'; import './main.spec'; import './app.spec'; +import './providers/https.spec'; // import './providers/analytics.spec'; // import './providers/auth.spec'; // import './providers/database.spec'; diff --git a/spec/main.spec.ts b/spec/main.spec.ts index 4db6f9b..7ded7c0 100644 --- a/spec/main.spec.ts +++ b/spec/main.spec.ts @@ -88,6 +88,14 @@ describe('main', () => { expect(context.authType).to.equal('USER'); }); + it('should throw when passed invalid options', () => { + const wrapped = wrap(constructCF()); + expect(() => wrapped('data', { + auth: { uid: 'abc' }, + isInvalid: true, + } as any)).to.throw(); + }); + it('should generate the appropriate resource based on params', () => { const params = { wildcard: 'a', diff --git a/spec/providers/https.spec.ts b/spec/providers/https.spec.ts new file mode 100644 index 0000000..7022b4d --- /dev/null +++ b/spec/providers/https.spec.ts @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import * as functions from 'firebase-functions'; +import fft = require('../../src/index'); + +const cfToUpperCaseOnRequest = functions.https.onRequest((req, res) => { + res.json({msg: req.params.message.toUpperCase()}); +}); + +const cfToUpperCaseOnCall = functions.https.onCall((data, context) => { + const result = { + msg: data.message.toUpperCase(), + from: 'anonymous', + }; + + if (context.auth && context.auth.uid) { + result.from = context.auth.uid; + } + + return result; +}); + +describe('providers/https', () => { + it('should not throw when passed onRequest function', async () => { + const test = fft(); + /* + Note that we must cast the function to any here because onRequst functions + do not fulfill Runnable<>, so these checks are solely for usage of this lib + in JavaScript test suites. + */ + expect(() => test.wrap(cfToUpperCaseOnRequest as any)).to.throw(); + }); + + it('should run the wrapped onCall function and return result', async () => { + const test = fft(); + const result = await test.wrap(cfToUpperCaseOnCall)({message: 'lowercase'}); + expect(result).to.deep.equal({msg: 'LOWERCASE', from: 'anonymous'}); + }); + + it('should accept auth params', async () => { + const test = fft(); + const options = {auth: {uid: 'abc'}}; + const result = await test.wrap(cfToUpperCaseOnCall)({message: 'lowercase'}, options); + expect(result).to.deep.equal({msg: 'LOWERCASE', from: 'abc'}); + }); +}); diff --git a/src/main.ts b/src/main.ts index 6bfba38..2fc9dc0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,7 +22,7 @@ import { has, merge, random, get } from 'lodash'; -import { CloudFunction, EventContext, Resource, Change } from 'firebase-functions'; +import { CloudFunction, EventContext, Change } from 'firebase-functions'; /** Fields of the event context that can be overridden/customized. */ export type EventContextOptions = { @@ -34,53 +34,97 @@ export type EventContextOptions = { * If omitted, random values will be generated. */ params?: { [option: string]: any }; - /** (Only for database functions.) Firebase auth variable representing the user that triggered + /** (Only for database functions and https.onCall.) Firebase auth variable representing the user that triggered * the function. Defaults to null. */ auth?: any; - /** (Only for database functions.) The authentication state of the user that triggered the function. + /** (Only for database and https.onCall functions.) The authentication state of the user that triggered the function. * Default is 'UNAUTHENTICATED'. */ authType?: 'ADMIN' | 'USER' | 'UNAUTHENTICATED'; }; +/** Fields of the callable context that can be overridden/customized. */ +export type CallableContextOptions = { + /** + * The result of decoding and verifying a Firebase Auth ID token. + */ + auth?: any; + + /** + * An unverified token for a Firebase Instance ID. + */ + instanceIdToken?: string; +}; + +/* Fields for both Event and Callable contexts, checked at runtime */ +export type ContextOptions = EventContextOptions | CallableContextOptions; + /** A function that can be called with test data and optional override values for the event context. * It will subsequently invoke the cloud function it wraps with the provided test data and a generated event context. */ -export type WrappedFunction = (data: any, options?: EventContextOptions) => any | Promise; +export type WrappedFunction = (data: any, options?: ContextOptions) => any | Promise; /** Takes a cloud function to be tested, and returns a WrappedFunction which can be called in test code. */ export function wrap(cloudFunction: CloudFunction): WrappedFunction { if (!has(cloudFunction, '__trigger')) { throw new Error('Wrap can only be called on functions written with the firebase-functions SDK.'); } - if (!has(cloudFunction, '__trigger.eventTrigger')) { - throw new Error('Wrap function is only available for non-HTTP functions.'); + + if (has(cloudFunction, '__trigger.httpsTrigger') && + (get(cloudFunction, '__trigger.labels.deployment-callable') !== 'true')) { + throw new Error('Wrap function is only available for `onCall` HTTP functions, not `onRequest`.'); } + if (!has(cloudFunction, 'run')) { throw new Error('This library can only be used with functions written with firebase-functions v1.0.0 and above'); } - let wrapped: WrappedFunction = (data: T, options: EventContextOptions) => { - const defaultContext: EventContext = { - eventId: _makeEventId(), - resource: { - service: cloudFunction.__trigger.eventTrigger.service, - name: _makeResourceName(cloudFunction.__trigger.eventTrigger.resource, options? options.params: null), - }, - eventType: cloudFunction.__trigger.eventTrigger.eventType, - timestamp: (new Date()).toISOString(), - params: {}, - }; - if (defaultContext.eventType.match(/firebase.database/)) { - defaultContext.authType = 'UNAUTHENTICATED'; - defaultContext.auth = null; + + const isCallableFunction = get(cloudFunction, '__trigger.labels.deployment-callable') === 'true'; + + let wrapped: WrappedFunction = (data: T, options: ContextOptions) => { + // Although in Typescript we require `options` some of our JS samples do not pass it. + options = options || {}; + let context; + + if (isCallableFunction) { + _checkOptionValidity(['auth', 'instanceIdToken'], options); + let callableContextOptions = options as CallableContextOptions; + context = { + ...callableContextOptions, + rawRequest: 'rawRequest is not supported in firebase-functions-test', + }; + } else { + _checkOptionValidity(['eventId', 'timestamp', 'params', 'auth', 'authType'], options); + let eventContextOptions = options as EventContextOptions; + const defaultContext: EventContext = { + eventId: _makeEventId(), + resource: cloudFunction.__trigger.eventTrigger && { + service: cloudFunction.__trigger.eventTrigger.service, + name: _makeResourceName( + cloudFunction.__trigger.eventTrigger.resource, + has(eventContextOptions, 'params') && eventContextOptions.params, + ), + }, + eventType: get(cloudFunction, '__trigger.eventTrigger.eventType'), + timestamp: (new Date()).toISOString(), + params: {}, + }; + + if (has(defaultContext, 'eventType') && + defaultContext.eventType.match(/firebase.database/)) { + defaultContext.authType = 'UNAUTHENTICATED'; + defaultContext.auth = null; + } + context = merge({}, defaultContext, eventContextOptions); } - let context = merge({}, defaultContext, options); + return cloudFunction.run( data, context, ); }; + return wrapped; } @@ -99,6 +143,14 @@ function _makeEventId(): string { return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); } +function _checkOptionValidity(validFields: string[], options: {[s: string]: any}) { + Object.keys(options).forEach((key) => { + if (validFields.indexOf(key) === -1) { + throw new Error(`Options object ${JSON.stringify(options)} has invalid key "${key}"`); + } + }); +} + /** Make a Change object to be used as test data for Firestore and real time database onWrite and onUpdate functions. */ export function makeChange(before: T, after: T): Change { return Change.fromObjects(before, after);