Skip to content

Commit 8d11961

Browse files
Reroute Cloud Tasks to emulator when it is running (#2649)
* Redirect Task Enqueue Requests to emulator if it is running * Reroute Task Queue requests to emulator when it is running * Add tests for emulator redirection * Bypass service account check for tasks when running within the emulator * use fake service account when running in emulated mode and service account credentials are not provided * restore package-lock.json
1 parent 5d47529 commit 8d11961

File tree

2 files changed

+130
-12
lines changed

2 files changed

+130
-12
lines changed

src/functions/functions-api-client-internal.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { ComputeEngineCredential } from '../app/credential-internal';
2929
const CLOUD_TASKS_API_RESOURCE_PATH = 'projects/{projectId}/locations/{locationId}/queues/{resourceId}/tasks';
3030
const CLOUD_TASKS_API_URL_FORMAT = 'https://cloudtasks.googleapis.com/v2/' + CLOUD_TASKS_API_RESOURCE_PATH;
3131
const FIREBASE_FUNCTION_URL_FORMAT = 'https://{locationId}-{projectId}.cloudfunctions.net/{resourceId}';
32+
export const EMULATED_SERVICE_ACCOUNT_DEFAULT = '[email protected]';
3233

3334
const FIREBASE_FUNCTIONS_CONFIG_HEADERS = {
3435
'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}`
@@ -69,8 +70,8 @@ export class FunctionsApiClient {
6970
}
7071
if (!validator.isTaskId(id)) {
7172
throw new FirebaseFunctionsError(
72-
'invalid-argument', 'id can contain only letters ([A-Za-z]), numbers ([0-9]), '
73-
+ 'hyphens (-), or underscores (_). The maximum length is 500 characters.');
73+
'invalid-argument', 'id can contain only letters ([A-Za-z]), numbers ([0-9]), '
74+
+ 'hyphens (-), or underscores (_). The maximum length is 500 characters.');
7475
}
7576

7677
let resources: utils.ParsedResource;
@@ -91,7 +92,8 @@ export class FunctionsApiClient {
9192
}
9293

9394
try {
94-
const serviceUrl = await this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT.concat('/', id));
95+
const serviceUrl = tasksEmulatorUrl(resources, functionName)?.concat('/', id)
96+
?? await this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT.concat('/', id));
9597
const request: HttpRequestConfig = {
9698
method: 'DELETE',
9799
url: serviceUrl,
@@ -144,7 +146,10 @@ export class FunctionsApiClient {
144146

145147
const task = this.validateTaskOptions(data, resources, opts);
146148
try {
147-
const serviceUrl = await this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT);
149+
const serviceUrl =
150+
tasksEmulatorUrl(resources, functionName) ??
151+
await this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT);
152+
148153
const taskPayload = await this.updateTaskPayload(task, resources, extensionId);
149154
const request: HttpRequestConfig = {
150155
method: 'POST',
@@ -237,7 +242,7 @@ export class FunctionsApiClient {
237242
serviceAccountEmail: '',
238243
},
239244
body: Buffer.from(JSON.stringify({ data })).toString('base64'),
240-
headers: {
245+
headers: {
241246
'Content-Type': 'application/json',
242247
...opts?.headers,
243248
}
@@ -252,7 +257,7 @@ export class FunctionsApiClient {
252257
if ('scheduleTime' in opts && 'scheduleDelaySeconds' in opts) {
253258
throw new FirebaseFunctionsError(
254259
'invalid-argument', 'Both scheduleTime and scheduleDelaySeconds are provided. '
255-
+ 'Only one value should be set.');
260+
+ 'Only one value should be set.');
256261
}
257262
if ('scheduleTime' in opts && typeof opts.scheduleTime !== 'undefined') {
258263
if (!(opts.scheduleTime instanceof Date)) {
@@ -275,15 +280,15 @@ export class FunctionsApiClient {
275280
|| opts.dispatchDeadlineSeconds > 1800) {
276281
throw new FirebaseFunctionsError(
277282
'invalid-argument', 'dispatchDeadlineSeconds must be a non-negative duration in seconds '
278-
+ 'and must be in the range of 15s to 30 mins.');
283+
+ 'and must be in the range of 15s to 30 mins.');
279284
}
280285
task.dispatchDeadline = `${opts.dispatchDeadlineSeconds}s`;
281286
}
282287
if ('id' in opts && typeof opts.id !== 'undefined') {
283288
if (!validator.isTaskId(opts.id)) {
284289
throw new FirebaseFunctionsError(
285290
'invalid-argument', 'id can contain only letters ([A-Za-z]), numbers ([0-9]), '
286-
+ 'hyphens (-), or underscores (_). The maximum length is 500 characters.');
291+
+ 'hyphens (-), or underscores (_). The maximum length is 500 characters.');
287292
}
288293
const resourcePath = utils.formatString(CLOUD_TASKS_API_RESOURCE_PATH, {
289294
projectId: resources.projectId,
@@ -304,9 +309,14 @@ export class FunctionsApiClient {
304309
}
305310

306311
private async updateTaskPayload(task: Task, resources: utils.ParsedResource, extensionId?: string): Promise<Task> {
307-
const functionUrl = validator.isNonEmptyString(task.httpRequest.url)
308-
? task.httpRequest.url
312+
const defaultUrl = process.env.CLOUD_TASKS_EMULATOR_HOST ?
313+
''
309314
: await this.getUrl(resources, FIREBASE_FUNCTION_URL_FORMAT);
315+
316+
const functionUrl = validator.isNonEmptyString(task.httpRequest.url)
317+
? task.httpRequest.url
318+
: defaultUrl;
319+
310320
task.httpRequest.url = functionUrl;
311321
// When run from a deployed extension, we should be using ComputeEngineCredentials
312322
if (validator.isNonEmptyString(extensionId) && this.app.options.credential instanceof ComputeEngineCredential) {
@@ -315,8 +325,16 @@ export class FunctionsApiClient {
315325
// Don't send httpRequest.oidcToken if we set Authorization header, or Cloud Tasks will overwrite it.
316326
delete task.httpRequest.oidcToken;
317327
} else {
318-
const account = await this.getServiceAccount();
319-
task.httpRequest.oidcToken = { serviceAccountEmail: account };
328+
try {
329+
const account = await this.getServiceAccount();
330+
task.httpRequest.oidcToken = { serviceAccountEmail: account };
331+
} catch (e) {
332+
if (process.env.CLOUD_TASKS_EMULATOR_HOST) {
333+
task.httpRequest.oidcToken = { serviceAccountEmail: EMULATED_SERVICE_ACCOUNT_DEFAULT };
334+
} else {
335+
throw e;
336+
}
337+
}
320338
}
321339
return task;
322340
}
@@ -417,3 +435,10 @@ export class FirebaseFunctionsError extends PrefixedFirebaseError {
417435
(this as any).__proto__ = FirebaseFunctionsError.prototype;
418436
}
419437
}
438+
439+
function tasksEmulatorUrl(resources: utils.ParsedResource, functionName: string): string | undefined {
440+
if (process.env.CLOUD_TASKS_EMULATOR_HOST) {
441+
return `http://${process.env.CLOUD_TASKS_EMULATOR_HOST}/projects/${resources.projectId}/locations/${resources.locationId}/queues/${functionName}/tasks`;
442+
}
443+
return undefined;
444+
}

test/unit/functions/functions-api-client-internal.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { FirebaseFunctionsError, FunctionsApiClient, Task } from '../../../src/f
2929
import { HttpClient } from '../../../src/utils/api-request';
3030
import { FirebaseAppError } from '../../../src/utils/error';
3131
import { deepCopy } from '../../../src/utils/deep-copy';
32+
import { EMULATED_SERVICE_ACCOUNT_DEFAULT } from '../../../src/functions/functions-api-client-internal';
3233

3334
const expect = chai.expect;
3435

@@ -90,6 +91,10 @@ describe('FunctionsApiClient', () => {
9091
const CLOUD_TASKS_URL_FULL_RESOURCE = `https://cloudtasks.googleapis.com/v2/projects/${CUSTOM_PROJECT_ID}/locations/${CUSTOM_REGION}/queues/${FUNCTION_NAME}/tasks`;
9192

9293
const CLOUD_TASKS_URL_PARTIAL_RESOURCE = `https://cloudtasks.googleapis.com/v2/projects/${mockOptions.projectId}/locations/${CUSTOM_REGION}/queues/${FUNCTION_NAME}/tasks`;
94+
95+
const CLOUD_TASKS_EMULATOR_HOST = '127.0.0.1:9499';
96+
97+
const CLOUD_TASKS_URL_EMULATOR = `http://${CLOUD_TASKS_EMULATOR_HOST}/projects/${mockOptions.projectId}/locations/${DEFAULT_REGION}/queues/${FUNCTION_NAME}/tasks`;
9398

9499
const clientWithoutProjectId = new FunctionsApiClient(mocks.mockCredentialApp());
95100

@@ -106,6 +111,9 @@ describe('FunctionsApiClient', () => {
106111
afterEach(() => {
107112
_.forEach(stubs, (stub) => stub.restore());
108113
stubs = [];
114+
if (process.env.CLOUD_TASKS_EMULATOR_HOST) {
115+
delete process.env.CLOUD_TASKS_EMULATOR_HOST;
116+
}
109117
return app.delete();
110118
});
111119

@@ -477,8 +485,79 @@ describe('FunctionsApiClient', () => {
477485
});
478486
});
479487
});
488+
489+
it('should redirect to the emulator when CLOUD_TASKS_EMULATOR_HOST is set', () => {
490+
const expectedPayload = deepCopy(TEST_TASK_PAYLOAD);
491+
const stub = sinon
492+
.stub(HttpClient.prototype, 'send')
493+
.resolves(utils.responseFrom({}, 200));
494+
stubs.push(stub);
495+
process.env.CLOUD_TASKS_EMULATOR_HOST = CLOUD_TASKS_EMULATOR_HOST;
496+
return apiClient.enqueue({}, FUNCTION_NAME, '', { uri: TEST_TASK_PAYLOAD.httpRequest.url })
497+
.then(() => {
498+
expect(stub).to.have.been.calledOnce.and.calledWith({
499+
method: 'POST',
500+
url: CLOUD_TASKS_URL_EMULATOR,
501+
headers: EXPECTED_HEADERS,
502+
data: {
503+
task: expectedPayload
504+
}
505+
});
506+
});
507+
});
508+
509+
it('should leave empty urls alone when CLOUD_TASKS_EMULATOR_HOST is set', () => {
510+
const expectedPayload = deepCopy(TEST_TASK_PAYLOAD);
511+
expectedPayload.httpRequest.url = '';
512+
const stub = sinon
513+
.stub(HttpClient.prototype, 'send')
514+
.resolves(utils.responseFrom({}, 200));
515+
stubs.push(stub);
516+
process.env.CLOUD_TASKS_EMULATOR_HOST = CLOUD_TASKS_EMULATOR_HOST;
517+
return apiClient.enqueue({}, FUNCTION_NAME)
518+
.then(() => {
519+
expect(stub).to.have.been.calledOnce.and.calledWith({
520+
method: 'POST',
521+
url: CLOUD_TASKS_URL_EMULATOR,
522+
headers: EXPECTED_HEADERS,
523+
data: {
524+
task: expectedPayload
525+
}
526+
});
527+
});
528+
});
529+
530+
it('should use a fake service account if the emulator is running and no service account is defined', () => {
531+
app = mocks.appWithOptions({
532+
credential: new mocks.MockCredential(),
533+
projectId: 'test-project',
534+
serviceAccountId: ''
535+
});
536+
apiClient = new FunctionsApiClient(app);
537+
538+
const expectedPayload = deepCopy(TEST_TASK_PAYLOAD);
539+
expectedPayload.httpRequest.oidcToken = { serviceAccountEmail: EMULATED_SERVICE_ACCOUNT_DEFAULT };
540+
const stub = sinon
541+
.stub(HttpClient.prototype, 'send')
542+
.resolves(utils.responseFrom({}, 200));
543+
stubs.push(stub);
544+
process.env.CLOUD_TASKS_EMULATOR_HOST = CLOUD_TASKS_EMULATOR_HOST;
545+
return apiClient.enqueue({}, FUNCTION_NAME, '', { uri: TEST_TASK_PAYLOAD.httpRequest.url })
546+
.then(() => {
547+
expect(stub).to.have.been.calledOnce.and.calledWith({
548+
method: 'POST',
549+
url: CLOUD_TASKS_URL_EMULATOR,
550+
headers: EXPECTED_HEADERS,
551+
data: {
552+
task: expectedPayload
553+
}
554+
});
555+
});
556+
})
557+
480558
});
481559

560+
482561
describe('delete', () => {
483562
for (const invalidTaskId of [1234, 'task!', 'id:0', '[1234]', '(1234)']) {
484563
it(`should throw given an invalid task ID: ${invalidTaskId}`, () => {
@@ -514,6 +593,20 @@ describe('FunctionsApiClient', () => {
514593
expect(apiClient.delete('nonexistent-task', FUNCTION_NAME)).to.eventually.not.throw(utils.errorFrom({}, 404));
515594
});
516595

596+
it('should redirect to the emulator when CLOUD_TASKS_EMULATOR_HOST is set', async () => {
597+
process.env.CLOUD_TASKS_EMULATOR_HOST = CLOUD_TASKS_EMULATOR_HOST;
598+
const stub = sinon
599+
.stub(HttpClient.prototype, 'send')
600+
.resolves(utils.responseFrom({}, 200));
601+
stubs.push(stub);
602+
await apiClient.delete('mock-task', FUNCTION_NAME);
603+
expect(stub).to.have.been.calledWith({
604+
method: 'DELETE',
605+
url: CLOUD_TASKS_URL_EMULATOR.concat('/', 'mock-task'),
606+
headers: EXPECTED_HEADERS,
607+
});
608+
});
609+
517610
it('should throw on non-404 HTTP errors', () => {
518611
const stub = sinon
519612
.stub(HttpClient.prototype, 'send')

0 commit comments

Comments
 (0)