Skip to content
129 changes: 129 additions & 0 deletions src/gcp/cloudfunctionsv2.spec.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran this through gemini and it also suggested some additional test cases:

  1. updateFunction Mask Verification: updateFunction unit tests in src/gcp/cloudfunctionsv2.spec.ts currently only verify default environment variable injection. A test case should be added to verify that updating a function with a custom or null buildConfig.serviceAccount correctly populates the updateMask query parameter.
  2. endpointFromFunction Reverse Mapping Tests: While the forward transformation (functionFromEndpoint) is thoroughly tested, the inverse transformation (endpointFromFunction) should include test cases ensuring that when serviceAccountEmail is null or omitted in a GCF API response, endpoint.serviceAccount is correctly mapped to null or undefined.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect } from "chai";
import * as nock from "nock";
import type { ParsedUrlQuery } from "querystring";

import * as cloudfunctionsv2 from "./cloudfunctionsv2";
import * as backend from "../deploy/functions/backend";
Expand Down Expand Up @@ -244,6 +245,10 @@

const fullGcfFunction: cloudfunctionsv2.InputCloudFunction = {
...CLOUD_FUNCTION_V2,
buildConfig: {
...CLOUD_FUNCTION_V2.buildConfig,
serviceAccount: "projects/project/serviceAccounts/inlined@google.com",
},
labels: {
...CLOUD_FUNCTION_V2.labels,
foo: "bar",
Expand Down Expand Up @@ -314,7 +319,7 @@
serviceConfig: {
...HAVE_CLOUD_FUNCTION_V2.serviceConfig,
directVpcNetworkInterface: [{ network: "my-net" }],
directVpcEgress: "ALL_TRAFFIC" as any,

Check warning on line 322 in src/gcp/cloudfunctionsv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 322 in src/gcp/cloudfunctionsv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
uri: RUN_URI,
service: "service",
},
Expand All @@ -328,7 +333,7 @@
serviceConfig: {
...HAVE_CLOUD_FUNCTION_V2.serviceConfig,
directVpcNetworkInterface: [{ network: "my-net" }],
directVpcEgress: "VPC_EGRESS_UNSPECIFIED" as any,

Check warning on line 336 in src/gcp/cloudfunctionsv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 336 in src/gcp/cloudfunctionsv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
uri: RUN_URI,
service: "service",
},
Expand Down Expand Up @@ -402,6 +407,10 @@

const saGcfFunction: cloudfunctionsv2.InputCloudFunction = {
...CLOUD_FUNCTION_V2,
buildConfig: {
...CLOUD_FUNCTION_V2.buildConfig,
serviceAccount: "projects/project/serviceAccounts/sa@google.com",
},
eventTrigger: {
eventType: events.v2.DATABASE_EVENTS[0],
eventFilters: [
Expand Down Expand Up @@ -475,6 +484,10 @@
}),
).to.deep.equal({
...CLOUD_FUNCTION_V2,
buildConfig: {
...CLOUD_FUNCTION_V2.buildConfig,
serviceAccount: `projects/${ENDPOINT.project}/serviceAccounts/sa@${ENDPOINT.project}.iam.gserviceaccount.com`,
},
serviceConfig: {
...CLOUD_FUNCTION_V2.serviceConfig,
serviceAccountEmail: `sa@${ENDPOINT.project}.iam.gserviceaccount.com`,
Expand All @@ -491,6 +504,10 @@
}),
).to.deep.equal({
...CLOUD_FUNCTION_V2,
buildConfig: {
...CLOUD_FUNCTION_V2.buildConfig,
serviceAccount: null,
},
serviceConfig: {
...CLOUD_FUNCTION_V2.serviceConfig,
serviceAccountEmail: null,
Expand Down Expand Up @@ -776,6 +793,45 @@
});
});

it("should preserve null service account", () => {
expect(
cloudfunctionsv2.endpointFromFunction({
...HAVE_CLOUD_FUNCTION_V2,
serviceConfig: {
...HAVE_CLOUD_FUNCTION_V2.serviceConfig,
service: "service",
uri: RUN_URI,
serviceAccountEmail: null,
},
}),
).to.deep.equal({
...ENDPOINT,
httpsTrigger: {},
platform: "gcfv2",
uri: GCF_URL,
serviceAccount: null,
});
});

it("should leave service account unset when omitted", () => {
const endpoint = cloudfunctionsv2.endpointFromFunction({
...HAVE_CLOUD_FUNCTION_V2,
serviceConfig: {
...HAVE_CLOUD_FUNCTION_V2.serviceConfig,
service: "service",
uri: RUN_URI,
},
});

expect(endpoint).to.deep.equal({
...ENDPOINT,
httpsTrigger: {},
platform: "gcfv2",
uri: GCF_URL,
});
expect(endpoint).not.to.have.property("serviceAccount");
});

it("should transform fields", () => {
const extraFields: backend.ServiceConfiguration = {
minInstances: 1,
Expand Down Expand Up @@ -905,15 +961,15 @@

const scope = nock(functionsV2Origin())
.post("/v2/projects/project/locations/region/functions", (body) => {
expect(body.serviceConfig.environmentVariables).to.have.property(

Check warning on line 964 in src/gcp/cloudfunctionsv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .serviceConfig on an `any` value
"LOG_EXECUTION_ID",
"true",
);
expect(body.serviceConfig.environmentVariables).to.have.property(

Check warning on line 968 in src/gcp/cloudfunctionsv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .serviceConfig on an `any` value
"FUNCTION_TARGET",
"function",
);
expect(body.buildConfig.environmentVariables).to.have.property(

Check warning on line 972 in src/gcp/cloudfunctionsv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .buildConfig on an `any` value
"GOOGLE_NODE_RUN_SCRIPTS",
"",
);
Expand All @@ -928,18 +984,31 @@
});

describe("updateFunction", () => {
const expectServiceAccountUpdateMask = (queryParams: ParsedUrlQuery): boolean => {
const updateMask = queryParams.updateMask;
expect(updateMask).to.be.a("string");
if (typeof updateMask !== "string") {
return false;
}
expect(updateMask.split(",")).to.include.members([
"buildConfig.serviceAccount",
"serviceConfig.serviceAccountEmail",
]);
return true;
};

it("should set default environment variables", async () => {
const scope = nock(functionsV2Origin())
.patch("/v2/projects/project/locations/region/functions/id", (body) => {
expect(body.serviceConfig.environmentVariables).to.have.property(

Check warning on line 1003 in src/gcp/cloudfunctionsv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .serviceConfig on an `any` value
"LOG_EXECUTION_ID",
"true",
);
expect(body.serviceConfig.environmentVariables).to.have.property(

Check warning on line 1007 in src/gcp/cloudfunctionsv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .serviceConfig on an `any` value
"FUNCTION_TARGET",
"function",
);
expect(body.buildConfig.environmentVariables).to.have.property(

Check warning on line 1011 in src/gcp/cloudfunctionsv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .buildConfig on an `any` value
"GOOGLE_NODE_RUN_SCRIPTS",
"",
);
Expand All @@ -951,5 +1020,65 @@
await cloudfunctionsv2.updateFunction(CLOUD_FUNCTION_V2);
expect(scope.isDone()).to.be.true;
});

it("should include custom service account fields in update mask", async () => {
const testFunction: cloudfunctionsv2.InputCloudFunction = {
...CLOUD_FUNCTION_V2,
buildConfig: {
...CLOUD_FUNCTION_V2.buildConfig,
serviceAccount: "projects/project/serviceAccounts/inlined@google.com",
},
serviceConfig: {
...CLOUD_FUNCTION_V2.serviceConfig,
serviceAccountEmail: "inlined@google.com",
},
};

const scope = nock(functionsV2Origin())
.patch(
"/v2/projects/project/locations/region/functions/id",
(body: cloudfunctionsv2.InputCloudFunction) => {
expect(body.buildConfig.serviceAccount).to.equal(
"projects/project/serviceAccounts/inlined@google.com",
);
expect(body.serviceConfig.serviceAccountEmail).to.equal("inlined@google.com");
return true;
},
)
.query(expectServiceAccountUpdateMask)
.reply(200, { name: "operations/123", done: true });

await cloudfunctionsv2.updateFunction(testFunction);
expect(scope.isDone()).to.be.true;
});

it("should include null service account fields in update mask", async () => {
const testFunction: cloudfunctionsv2.InputCloudFunction = {
...CLOUD_FUNCTION_V2,
buildConfig: {
...CLOUD_FUNCTION_V2.buildConfig,
serviceAccount: null,
},
serviceConfig: {
...CLOUD_FUNCTION_V2.serviceConfig,
serviceAccountEmail: null,
},
};

const scope = nock(functionsV2Origin())
.patch(
"/v2/projects/project/locations/region/functions/id",
(body: cloudfunctionsv2.InputCloudFunction) => {
expect(body.buildConfig.serviceAccount).to.equal(null);
expect(body.serviceConfig.serviceAccountEmail).to.equal(null);
return true;
},
)
.query(expectServiceAccountUpdateMask)
.reply(200, { name: "operations/123", done: true });

await cloudfunctionsv2.updateFunction(testFunction);
expect(scope.isDone()).to.be.true;
});
});
});
27 changes: 17 additions & 10 deletions src/gcp/cloudfunctionsv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface BuildConfig {
source: Source;
sourceToken?: string;
environmentVariables: Record<string, string>;
serviceAccount?: string | null;

// Output only
build?: string;
Expand Down Expand Up @@ -476,16 +477,22 @@ export function functionFromEndpoint(endpoint: backend.Endpoint): InputCloudFunc
"ingressSettings",
"timeoutSeconds",
);
proto.convertIfPresent(
gcfFunction.serviceConfig,
endpoint,
"serviceAccountEmail",
"serviceAccount",
(from) =>
!from
? null
: proto.formatServiceAccount(from, endpoint.project, true /* removeTypePrefix */),
);

// endpoint.serviceAccount is one user-facing option, but the v2 API splits it
// across buildConfig.serviceAccount (resource name) and serviceConfig.serviceAccountEmail
// (email only). convertIfPresent handles one destination field at a time, which
// would duplicate the presence check, null handling, and email formatting.
if (Object.prototype.hasOwnProperty.call(endpoint, "serviceAccount")) {

@wandamora wandamora May 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can you leave a comment here on why we're not using convertIfPresent like how the rest of the function is using the proto API? This could help with readability.

const serviceAccount = endpoint.serviceAccount;
if (!serviceAccount) {
gcfFunction.buildConfig.serviceAccount = null;
gcfFunction.serviceConfig.serviceAccountEmail = null;
} else {
const email = proto.formatServiceAccount(serviceAccount, endpoint.project, true);
gcfFunction.buildConfig.serviceAccount = `projects/${endpoint.project}/serviceAccounts/${email}`;
gcfFunction.serviceConfig.serviceAccountEmail = email;
}
}
// Memory must be set because the default value of GCF gen 2 is Megabytes and
// we use mebibytes
const mem = endpoint.availableMemoryMb || backend.DEFAULT_MEMORY;
Expand Down
Loading