Skip to content

Commit f215d64

Browse files
Configure apptesting (#8879)
Add system to ensure apptesting is properly configured This change is inside of `firebase init apptesting`
1 parent 02c250d commit f215d64

File tree

7 files changed

+228
-2
lines changed

7 files changed

+228
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
- Added a deprecation warning for functions.config() to stderr on deploy and all functions:config commands. (#8808)
22
- Added analytics to track runtime config usage in functions deployments (#8870).
33
- Fixed issue where `__name__` fields with DESCENDING order were incorrectly filtered from index listings, causing duplicate index issues (#7629) and deployment conflicts (#8859). The fix now preserves `__name__` fields with explicit DESCENDING order while filtering out implicit ASCENDING `__name__` fields.
4+
- Add service account and service enablement to `firebase init apptesting`

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"mocha": "nyc mocha 'src/**/*.spec.{ts,js}'",
2727
"prepare": "npm run clean && npm run build:publish",
2828
"test": "npm run lint:quiet && npm run test:compile && npm run mocha",
29+
"test:apptesting": "npm run lint:quiet && npm run test:compile && nyc mocha 'src/apptesting/*.spec.{ts,js}'",
2930
"test:client-integration": "bash ./scripts/client-integration-tests/run.sh",
3031
"test:compile": "tsc --project tsconfig.compile.json",
3132
"test:management": "npm run lint:quiet && npm run test:compile && nyc mocha 'src/management/*.spec.{ts,js}'",
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import * as sinon from "sinon";
2+
import { expect } from "chai";
3+
import * as ensureApiEnabled from "../ensureApiEnabled";
4+
import * as iam from "../gcp/iam";
5+
import * as rm from "../gcp/resourceManager";
6+
import * as prompt from "../prompt";
7+
import * as utils from "../utils";
8+
import { FirebaseError } from "../error";
9+
import * as apptesting from "./ensureProjectConfigured";
10+
11+
describe("ensureProjectConfigured", () => {
12+
const sandbox: sinon.SinonSandbox = sinon.createSandbox();
13+
14+
let serviceAccountHasRolesStub: sinon.SinonStub;
15+
let confirmStub: sinon.SinonStub;
16+
let ensureApiEnabledStub: sinon.SinonStub;
17+
let createServiceAccountStub: sinon.SinonStub;
18+
let addServiceAccountToRolesStub: sinon.SinonStub;
19+
let logWarningStub: sinon.SinonStub;
20+
21+
beforeEach(() => {
22+
serviceAccountHasRolesStub = sandbox.stub(rm, "serviceAccountHasRoles");
23+
confirmStub = sandbox.stub(prompt, "confirm");
24+
ensureApiEnabledStub = sandbox.stub(ensureApiEnabled, "ensure");
25+
createServiceAccountStub = sandbox.stub(iam, "createServiceAccount");
26+
addServiceAccountToRolesStub = sandbox.stub(rm, "addServiceAccountToRoles");
27+
logWarningStub = sandbox.stub(utils, "logWarning");
28+
});
29+
30+
afterEach(() => {
31+
sandbox.verifyAndRestore();
32+
});
33+
34+
const projectId = "test-project";
35+
const serviceAccount = "firebaseapptesting-test-runner@test-project.iam.gserviceaccount.com";
36+
const TEST_RUNNER_ROLE = "roles/firebaseapptesting.testRunner";
37+
38+
it("should ensure all necessary APIs are enabled", async () => {
39+
serviceAccountHasRolesStub.resolves(true);
40+
ensureApiEnabledStub.resolves();
41+
42+
await apptesting.ensureProjectConfigured(projectId);
43+
44+
expect(ensureApiEnabledStub).to.be.calledThrice;
45+
expect(ensureApiEnabledStub).to.be.calledWith(projectId, sinon.match.any, "storage", false);
46+
expect(ensureApiEnabledStub).to.be.calledWith(projectId, sinon.match.any, "run", false);
47+
expect(ensureApiEnabledStub).to.be.calledWith(
48+
projectId,
49+
sinon.match.any,
50+
"artifactregistry",
51+
false,
52+
);
53+
});
54+
55+
it("should do nothing if service account is already configured", async () => {
56+
ensureApiEnabledStub.resolves();
57+
serviceAccountHasRolesStub.resolves(true);
58+
59+
await apptesting.ensureProjectConfigured(projectId);
60+
61+
expect(serviceAccountHasRolesStub).to.be.calledWith(
62+
projectId,
63+
serviceAccount,
64+
[TEST_RUNNER_ROLE],
65+
true,
66+
);
67+
expect(confirmStub).to.not.have.been.called;
68+
expect(createServiceAccountStub).to.not.have.been.called;
69+
expect(addServiceAccountToRolesStub).to.not.have.been.called;
70+
});
71+
72+
it("should provision service account if user confirms", async () => {
73+
ensureApiEnabledStub.resolves();
74+
serviceAccountHasRolesStub.resolves(false);
75+
confirmStub.resolves(true);
76+
createServiceAccountStub.resolves();
77+
addServiceAccountToRolesStub.resolves();
78+
79+
await apptesting.ensureProjectConfigured(projectId);
80+
81+
expect(serviceAccountHasRolesStub).to.be.calledWith(
82+
projectId,
83+
serviceAccount,
84+
[TEST_RUNNER_ROLE],
85+
true,
86+
);
87+
expect(confirmStub).to.be.calledOnce;
88+
expect(createServiceAccountStub).to.be.calledWith(
89+
projectId,
90+
"firebaseapptesting-test-runner",
91+
sinon.match.string,
92+
sinon.match.string,
93+
);
94+
expect(addServiceAccountToRolesStub).to.be.calledWith(
95+
projectId,
96+
serviceAccount,
97+
[TEST_RUNNER_ROLE],
98+
true,
99+
);
100+
});
101+
102+
it("should throw error if user denies service account creation", async () => {
103+
ensureApiEnabledStub.resolves();
104+
serviceAccountHasRolesStub.resolves(false);
105+
confirmStub.resolves(false);
106+
107+
await expect(apptesting.ensureProjectConfigured(projectId)).to.be.rejectedWith(
108+
FirebaseError,
109+
/Firebase App Testing requires a service account/,
110+
);
111+
112+
expect(confirmStub).to.be.calledOnce;
113+
expect(createServiceAccountStub).to.not.have.been.called;
114+
expect(addServiceAccountToRolesStub).to.not.have.been.called;
115+
});
116+
117+
it("should handle service account already exists error", async () => {
118+
ensureApiEnabledStub.resolves();
119+
serviceAccountHasRolesStub.resolves(false);
120+
confirmStub.resolves(true);
121+
createServiceAccountStub.rejects(new FirebaseError("Already exists", { status: 409 }));
122+
addServiceAccountToRolesStub.resolves();
123+
124+
await apptesting.ensureProjectConfigured(projectId);
125+
126+
expect(createServiceAccountStub).to.be.calledOnce;
127+
expect(addServiceAccountToRolesStub).to.be.calledOnce;
128+
});
129+
130+
it("should handle addServiceAccountToRoles 400 error", async () => {
131+
ensureApiEnabledStub.resolves();
132+
serviceAccountHasRolesStub.resolves(false);
133+
confirmStub.resolves(true);
134+
createServiceAccountStub.resolves();
135+
addServiceAccountToRolesStub.rejects(new FirebaseError("Bad request", { status: 400 }));
136+
137+
await apptesting.ensureProjectConfigured(projectId);
138+
139+
expect(addServiceAccountToRolesStub).to.be.calledOnce;
140+
expect(logWarningStub).to.be.calledWith(
141+
`Your App Testing runner service account, "${serviceAccount}", is still being provisioned in the background. If you encounter an error, please try again after a few moments.`,
142+
);
143+
});
144+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { addServiceAccountToRoles, serviceAccountHasRoles } from "../gcp/resourceManager";
2+
import { ensure } from "../ensureApiEnabled";
3+
import { appTestingOrigin } from "../api";
4+
import { logBullet, logWarning } from "../utils";
5+
import { FirebaseError, getErrStatus } from "../error";
6+
import * as iam from "../gcp/iam";
7+
import { confirm } from "../prompt";
8+
9+
const TEST_RUNNER_ROLE = "roles/firebaseapptesting.testRunner";
10+
const TEST_RUNNER_SERVICE_ACCOUNT_NAME = "firebaseapptesting-test-runner";
11+
12+
export async function ensureProjectConfigured(projectId: string) {
13+
await ensure(projectId, appTestingOrigin(), "storage", false);
14+
await ensure(projectId, appTestingOrigin(), "run", false);
15+
await ensure(projectId, appTestingOrigin(), "artifactregistry", false);
16+
const serviceAccount = runnerServiceAccount(projectId);
17+
18+
const serviceAccountExistsAndIsRunner = await serviceAccountHasRoles(
19+
projectId,
20+
serviceAccount,
21+
[TEST_RUNNER_ROLE],
22+
true,
23+
);
24+
if (!serviceAccountExistsAndIsRunner) {
25+
const grant = await confirm(
26+
`Firebase App Testing runs tests in Cloud Run using a service account, provision an account, "${serviceAccount}", with the role "${TEST_RUNNER_ROLE}"?`,
27+
);
28+
if (!grant) {
29+
logBullet(
30+
"You, or your project administrator, should run the following command to grant the required role:\n\n" +
31+
`\tgcloud projects add-iam-policy-binding ${projectId} \\\n` +
32+
`\t --member="serviceAccount:${serviceAccount}" \\\n` +
33+
`\t --role="${TEST_RUNNER_ROLE}"\n`,
34+
);
35+
throw new FirebaseError(
36+
`Firebase App Testing requires a service account named "${serviceAccount}" with the "${TEST_RUNNER_ROLE}" role to execute tests using Cloud Run`,
37+
);
38+
}
39+
await provisionServiceAccount(projectId, serviceAccount);
40+
}
41+
}
42+
43+
async function provisionServiceAccount(projectId: string, serviceAccount: string): Promise<void> {
44+
try {
45+
await iam.createServiceAccount(
46+
projectId,
47+
TEST_RUNNER_SERVICE_ACCOUNT_NAME,
48+
"Service Account used in Cloud Run, responsible for running tests",
49+
"Firebase App Testing Test Runner",
50+
);
51+
} catch (err: unknown) {
52+
// 409 Already Exists errors can safely be ignored.
53+
if (getErrStatus(err) !== 409) {
54+
throw err;
55+
}
56+
}
57+
try {
58+
await addServiceAccountToRoles(
59+
projectId,
60+
serviceAccount,
61+
[TEST_RUNNER_ROLE],
62+
/* skipAccountLookup= */ true,
63+
);
64+
} catch (err: unknown) {
65+
if (getErrStatus(err) === 400) {
66+
logWarning(
67+
`Your App Testing runner service account, "${serviceAccount}", is still being provisioned in the background. If you encounter an error, please try again after a few moments.`,
68+
);
69+
} else {
70+
throw err;
71+
}
72+
}
73+
}
74+
75+
function runnerServiceAccount(projectId: string): string {
76+
return `${TEST_RUNNER_SERVICE_ACCOUNT_NAME}@${projectId}.iam.gserviceaccount.com`;
77+
}

src/commands/init.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ if (isEnabled("genkit")) {
112112
if (isEnabled("apptesting")) {
113113
choices.push({
114114
value: "apptesting",
115-
name: "App Testing: create a smoke test",
115+
name: "App Testing: create a smoke test, enable Cloud APIs (storage, run, & artifactregistry), and add a service account.",
116116
checked: false,
117117
});
118118
}

src/ensureApiEnabled.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { expect } from "chai";
22
import * as nock from "nock";
33
import * as sinon from "sinon";
44
import { configstore } from "./configstore";
5-
65
import { check, ensure, POLL_SETTINGS } from "./ensureApiEnabled";
76

87
const FAKE_PROJECT_ID = "my_project";

src/init/features/apptesting/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Setup } from "../..";
33
import { input } from "../../../prompt";
44
import { Config } from "../../../config";
55
import { readTemplateSync } from "../../../templates";
6+
import { ensureProjectConfigured } from "../../../apptesting/ensureProjectConfigured";
67

78
const SMOKE_TEST_YAML_TEMPLATE = readTemplateSync("init/apptesting/smoke_test.yaml");
89

@@ -23,6 +24,9 @@ export async function askQuestions(setup: Setup): Promise<void> {
2324
})),
2425
},
2526
};
27+
if (setup.projectId) {
28+
await ensureProjectConfigured(setup.projectId);
29+
}
2630
}
2731

2832
// Writes App Testing product specific configuration info.

0 commit comments

Comments
 (0)