Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion src/deploy/functions/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,11 @@
.to.be.rejectedWith(FirebaseError)
.then((error) => {
// Should always list latest runtimes
expect(error.message).to.include(latest("nodejs"));

Check warning on line 145 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .message on an `any` value
expect(error.message).to.include(latest("python"));

Check warning on line 146 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .message on an `any` value

// Should never list a decommissioned runtime
expect(error.message).to.not.include("nodejs6");

Check warning on line 149 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .message on an `any` value
});
});

Expand Down Expand Up @@ -728,7 +728,7 @@
...ENDPOINT_BASE,
httpsTrigger: {},
};
const have: backend.Endpoint = JSON.parse(JSON.stringify(want));

Check warning on line 731 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
have.timeoutSeconds = 120;

prepare.inferDetailsFromExisting(backend.of(want), backend.of(have), /* usedDotEnv= */ false);
Expand Down Expand Up @@ -985,7 +985,7 @@
await prepare.warnIfNewGenkitFunctionIsMissingSecrets(
backend.empty(),
backend.of(nonGenkitEndpoint),
{} as any,

Check warning on line 988 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 988 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `DeployOptions`
);
expect(confirm).to.not.be.called;
});
Expand All @@ -994,7 +994,7 @@
await prepare.warnIfNewGenkitFunctionIsMissingSecrets(
backend.empty(),
backend.of(genkitEndpointWithSecrets),
{} as any,

Check warning on line 997 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 997 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `DeployOptions`
);
expect(confirm).to.not.be.called;
});
Expand All @@ -1003,7 +1003,7 @@
await prepare.warnIfNewGenkitFunctionIsMissingSecrets(
backend.of(genkitEndpointWithoutSecrets),
backend.of(genkitEndpointWithoutSecrets),
{} as any,

Check warning on line 1006 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 1006 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `DeployOptions`
);
expect(confirm).to.not.be.called;
});
Expand Down Expand Up @@ -1200,12 +1200,15 @@

describe("discoverSecurityDetails", () => {
let testIamPermissionsStub: sinon.SinonStub;
let generateManagedSANameStub: sinon.SinonStub;

beforeEach(() => {
testIamPermissionsStub = sinon
.stub(iam, "testIamPermissions")
.resolves({ passed: true } as any);
sinon.stub(iam, "generateManagedServiceAccountName").resolves("firebase-fn-123");
generateManagedSANameStub = sinon
.stub(iam, "generateManagedServiceAccountName")
.resolves("firebase-fn-123");
Comment on lines +1209 to +1211

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.

medium

When the fix for reusing existing managed service accounts is implemented, discoverSecurityDetails will likely call iam.getServiceAccount to check if the SA already exists in the project. Since iam.getServiceAccount is not stubbed in the beforeEach block of describe("discoverSecurityDetails"), all other tests in this suite that call discoverSecurityDetails will attempt to make real API calls and fail.\n\nTo prevent this, stub iam.getServiceAccount in the beforeEach block to reject with a 404 error by default, representing the happy path where the SA does not yet exist.

      generateManagedSANameStub = sinon\n        .stub(iam, "generateManagedServiceAccountName")\n        .resolves("firebase-fn-123");\n      sinon.stub(iam, "getServiceAccount").rejects({ status: 404 });

sinon.stub(resourcemanager, "getServiceAccountRoles").resolves([]);
});

Expand All @@ -1229,6 +1232,44 @@
expect(e.labels?.["firebase-declarative-security-etag"]).to.equal(result.newEtag);
});

it("reuses an existing managed service account in the project when no deployed functions reference it (BUG: currently generates a new one)", async () => {
// Scenario: a previous deploy created the managed SA and granted it roles, but
// function creation failed, so `have` is empty. The project still contains
// the managed SA, discoverable via IAM lookup.
const preexistingSA = "firebase-fn-1234567890@project.iam.gserviceaccount.com";
const getServiceAccountStub = sinon.stub(iam, "getServiceAccount").resolves({
name: `projects/project/serviceAccounts/${preexistingSA}`,
projectId: "project",
uniqueId: "12345",
email: preexistingSA,
displayName: "Firebase Functions managed service account",
etag: "etag",
description: "",
oauth2ClientId: "",
disabled: false,
});

const e: backend.Endpoint = {
...ENDPOINT,
};
const want = backend.of(e);
want.requiredRoles = ["roles/viewer"];
const have = backend.empty();

const result = await prepare.discoverSecurityDetails("default", want, have, "project");

// Desired behavior: reuse the SA that already exists in the project instead
// of generating a brand new random name (which orphans the previous SA and
// its project-level role grants on every failed deploy).
expect(
getServiceAccountStub.called || !generateManagedSANameStub.called,
"expected discoverSecurityDetails to look up existing managed service accounts " +
"in the project instead of unconditionally generating a new random SA name",
).to.be.true;
expect(result.managedSA).to.equal(preexistingSA);
expect(e.serviceAccount).to.equal(preexistingSA);
});

it("should reset endpoints to default service account when unenrolling (opting out)", async () => {
const e: backend.Endpoint = {
...ENDPOINT,
Expand Down
45 changes: 45 additions & 0 deletions src/deploy/functions/release/fabricator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,31 @@ describe("Fabricator", () => {
expect(gcfv2.deleteFunction).to.have.been.called;
});

it("retries function creation when a freshly created service account has not yet propagated (BUG: currently fails immediately)", async () => {
// GCF returns HTTP 404 when the deploy references a service account that
// was created moments ago and has not propagated through IAM yet.
const saNotFoundErr = new Error(
"Service account projects/-/serviceAccounts/firebase-fn-123@test-project.iam.gserviceaccount.com was not found. Please verify that the caller has iam.serviceAccounts.actAs permission on the service account.",
);
(saNotFoundErr as any).status = 404;
Comment on lines +676 to +679

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.

medium

Avoid using as any as an escape hatch to set the status property on the error object. This violates the repository style guide standard regarding TypeScript types.\n\nInstead, use a type intersection Error & { status?: number } to safely and cleanly add the status property.

      const saNotFoundErr = new Error(\n        "Service account projects/-/serviceAccounts/firebase-fn-123@test-project.iam.gserviceaccount.com was not found. Please verify that the caller has iam.serviceAccounts.actAs permission on the service account."\n      ) as Error & { status?: number };\n      saNotFoundErr.status = 404;
References
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

gcfv2.createFunction.onFirstCall().rejects(saNotFoundErr);
gcfv2.createFunction.resolves({ name: "op", done: false });
poller.pollOperation.resolves({ serviceConfig: { service: "service" } });
run.setInvokerCreate.resolves();

// Use the same executor type as a real deploy (release/index.ts) so that
// retry-code based mitigations are exercised too, with fast backoff.
const queueFab = new fabricator.Fabricator({
...ctorArgs,
functionExecutor: new executor.QueueExecutor({ retries: 3, backoff: 1, maxBackoff: 10 }),
});

const ep = endpoint({ httpsTrigger: {} }, { platform: "gcfv2" });
await queueFab.createV2Function(ep, new scraper.SourceTokenScraper());

expect(gcfv2.createFunction).to.have.been.calledTwice;
});

it("throws on set invoker failure", async () => {
gcfv2.createFunction.resolves({ name: "op", done: false });
poller.pollOperation.resolves({ serviceConfig: { service: "service" } });
Expand Down Expand Up @@ -2071,6 +2096,26 @@ describe("Fabricator", () => {
);
});

it("waits for a newly created service account to be visible before returning from grantNewRoles (BUG: no propagation check)", async () => {
// IAM service account creation is eventually consistent. grantNewRoles
// should poll getServiceAccount until the new SA is visible before the
// deploy proceeds to create functions that actAs it.
const getServiceAccountStub = sinon.stub(iam, "getServiceAccount").resolves({
name: "projects/test-project/serviceAccounts/firebase-fn-123@my-proj.iam.gserviceaccount.com",
email: "firebase-fn-123@my-proj.iam.gserviceaccount.com",
} as any);
Comment on lines +2103 to +2106

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.

medium

Avoid using as any to cast the resolved value of getServiceAccount. This violates the repository style guide standard regarding TypeScript types. Instead, provide a fully-formed ServiceAccount mock object.\n\nAdditionally, since grantNewRoles will be modified to call iam.getServiceAccount to poll for propagation, other tests in this describe block (such as "should create SA and grant roles in grantNewRoles") will also trigger this call. To prevent them from attempting real API calls and failing once the fix is implemented, consider stubbing iam.getServiceAccount in the beforeEach block of describe("declarative security phases") instead of only inside this specific test.

      const getServiceAccountStub = sinon.stub(iam, "getServiceAccount").resolves({\n        name: "projects/test-project/serviceAccounts/firebase-fn-123@my-proj.iam.gserviceaccount.com",\n        projectId: "test-project",\n        uniqueId: "123",\n        email: "firebase-fn-123@my-proj.iam.gserviceaccount.com",\n        displayName: "",\n        etag: "",\n        description: "",\n        oauth2ClientId: "",\n        disabled: false,\n      });
References
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

const plan: planner.CodebasePlan = {
regionalChangesets: {},
serviceAccountToCreate: "firebase-fn-123@my-proj.iam.gserviceaccount.com",
managedServiceAccount: "firebase-fn-123@my-proj.iam.gserviceaccount.com",
};

await fab.grantNewRoles(plan, "default");

expect(createServiceAccountStub).to.have.been.called;
expect(getServiceAccountStub).to.have.been.called;
});

it("should remove roles or delete SA in removeOldRoles", async () => {
const plan: planner.CodebasePlan = {
regionalChangesets: {},
Expand Down
Loading