diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef1b87935e..d45af0402fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,4 +3,5 @@ - Updated dependencies to address security vulnerabilities, including `protobufjs`, `tar`, `@grpc/grpc-js`, `express`, `undici`, `hono`, `tmp`, and `form-data`. - Updated the Firebase SQL Connect local toolkit to v3.4.22, which includes the following changes: - [fixed] Disallow using the GraphQL root operation type names (`Query`, `Mutation`, `Subscription`) as `@table` or `@view` types. +- Added an optional step to configure Crashlytics email alerts during `crashlytics:onboard:web`. - Fix `404` errors during `crashlytics:sourcemap:upload` when re-uploading a source map with the same obfuscated file path across different app versions diff --git a/src/crashlytics/alerts.spec.ts b/src/crashlytics/alerts.spec.ts new file mode 100644 index 00000000000..52efbec40d7 --- /dev/null +++ b/src/crashlytics/alerts.spec.ts @@ -0,0 +1,363 @@ +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +import nock from "../test/helpers/nock"; + +import { cloudMonitoringOrigin, crashlyticsApiOrigin } from "../api"; +import { FirebaseError } from "../error"; +import { + addChannelToPolicy, + createEmailChannel, + enableAlert, + enableAlerts, + fetchFirebaseEmailChannel, + FIREBASE_CHANNEL_LABEL, + FIREBASE_EMAIL_CHANNEL_DISPLAY_NAME_SUFFIX, + generateAlertPolicy, + NOTIFICATION_CHANNELS_MASK_PATH, + resolveEmailNotificationChannel, +} from "./alerts"; +import { AlertType } from "./types"; + +chai.use(chaiAsPromised); +const expect = chai.expect; + +describe("crashlytics alerts", () => { + const projectId = "test-project"; + const appId = "1:1234567890:web:abcdef1234567890"; + const userEmail = "user@example.com"; + const channelName = `projects/${projectId}/notificationChannels/67890`; + const newIssuePolicyName = `projects/${projectId}/alertPolicies/11111`; + const regressedIssuePolicyName = `projects/${projectId}/alertPolicies/22222`; + + const expectedFilter = `type = "email" AND labels.email_address = "${userEmail}" AND user_labels.${FIREBASE_CHANNEL_LABEL} = "true"`; + + const emailChannel = { + name: channelName, + type: "email", + displayName: `${userEmail}${FIREBASE_EMAIL_CHANNEL_DISPLAY_NAME_SUFFIX}`, + labels: { email_address: userEmail }, + userLabels: { [FIREBASE_CHANNEL_LABEL]: "true" }, + }; + + before(() => { + nock.disableNetConnect(); + }); + + after(() => { + nock.enableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + describe("generateAlertPolicy", () => { + it("should generate an alert policy and return its resource name", async () => { + nock(crashlyticsApiOrigin()) + .post(`/v1alpha/projects/${projectId}/apps/${appId}:generateAlertPolicy`, { + alertType: AlertType.ALERT_TYPE_NEW_ISSUE, + }) + .reply(200, { alertPolicy: newIssuePolicyName }); + + const res = await generateAlertPolicy(projectId, appId, AlertType.ALERT_TYPE_NEW_ISSUE); + expect(res).to.equal(newIssuePolicyName); + expect(nock.isDone()).to.be.true; + }); + + it("should throw a FirebaseError when API fails", async () => { + nock(crashlyticsApiOrigin()) + .post(`/v1alpha/projects/${projectId}/apps/${appId}:generateAlertPolicy`) + .reply(500, { error: "Internal Server Error" }); + + await expect( + generateAlertPolicy(projectId, appId, AlertType.ALERT_TYPE_NEW_ISSUE), + ).to.be.rejectedWith( + FirebaseError, + `Failed to generate Crashlytics alert policy for ${AlertType.ALERT_TYPE_NEW_ISSUE} on app ${appId}`, + ); + expect(nock.isDone()).to.be.true; + }); + }); + + describe("fetchFirebaseEmailChannel", () => { + it("should return existing Firebase email channel when found", async () => { + nock(cloudMonitoringOrigin()) + .get(`/v3/projects/${projectId}/notificationChannels`) + .query({ filter: expectedFilter }) + .reply(200, { notificationChannels: [emailChannel] }); + + const res = await fetchFirebaseEmailChannel(projectId, userEmail); + expect(res).to.deep.equal(emailChannel); + expect(nock.isDone()).to.be.true; + }); + + it("should return undefined when no matching channel exists", async () => { + nock(cloudMonitoringOrigin()) + .get(`/v3/projects/${projectId}/notificationChannels`) + .query({ filter: expectedFilter }) + .reply(200, {}); + + const res = await fetchFirebaseEmailChannel(projectId, userEmail); + expect(res).to.be.undefined; + expect(nock.isDone()).to.be.true; + }); + + it("should escape quotes and backslashes in userEmail when building filter", async () => { + const specialEmail = 'user\\"name@example.com'; + const escapedFilter = `type = "email" AND labels.email_address = "user\\\\\\"name@example.com" AND user_labels.${FIREBASE_CHANNEL_LABEL} = "true"`; + nock(cloudMonitoringOrigin()) + .get(`/v3/projects/${projectId}/notificationChannels`) + .query({ filter: escapedFilter }) + .reply(200, { notificationChannels: [emailChannel] }); + + const res = await fetchFirebaseEmailChannel(projectId, specialEmail); + expect(res).to.deep.equal(emailChannel); + expect(nock.isDone()).to.be.true; + }); + }); + + describe("createEmailChannel", () => { + it("should create a Firebase-labeled email notification channel", async () => { + nock(cloudMonitoringOrigin()) + .post(`/v3/projects/${projectId}/notificationChannels`, { + type: "email", + displayName: `${userEmail}${FIREBASE_EMAIL_CHANNEL_DISPLAY_NAME_SUFFIX}`, + labels: { email_address: userEmail }, + userLabels: { [FIREBASE_CHANNEL_LABEL]: "true" }, + }) + .reply(200, emailChannel); + + const res = await createEmailChannel(projectId, userEmail); + expect(res).to.deep.equal(emailChannel); + expect(nock.isDone()).to.be.true; + }); + }); + + describe("resolveEmailNotificationChannel", () => { + it("should reuse an existing channel if present", async () => { + nock(cloudMonitoringOrigin()) + .get(`/v3/projects/${projectId}/notificationChannels`) + .query({ filter: expectedFilter }) + .reply(200, { notificationChannels: [emailChannel] }); + + const res = await resolveEmailNotificationChannel(projectId, userEmail); + expect(res).to.deep.equal(emailChannel); + expect(nock.isDone()).to.be.true; + }); + + it("should create a new channel if none exists", async () => { + nock(cloudMonitoringOrigin()) + .get(`/v3/projects/${projectId}/notificationChannels`) + .query({ filter: expectedFilter }) + .reply(200, {}); + nock(cloudMonitoringOrigin()) + .post(`/v3/projects/${projectId}/notificationChannels`) + .reply(200, emailChannel); + + const res = await resolveEmailNotificationChannel(projectId, userEmail); + expect(res).to.deep.equal(emailChannel); + expect(nock.isDone()).to.be.true; + }); + }); + + describe("addChannelToPolicy", () => { + it("should append channelName when not already present", () => { + const policy = { name: newIssuePolicyName, notificationChannels: [] }; + const updated = addChannelToPolicy(policy, channelName); + expect(updated.notificationChannels).to.deep.equal([channelName]); + expect(policy.notificationChannels).to.deep.equal([]); + }); + + it("should not duplicate channelName when already present", () => { + const policy = { name: newIssuePolicyName, notificationChannels: [channelName] }; + const updated = addChannelToPolicy(policy, channelName); + expect(updated.notificationChannels).to.deep.equal([channelName]); + }); + }); + + describe("enableAlert", () => { + it("should generate policy, attach channel, and update policy", async () => { + const initialPolicy = { name: newIssuePolicyName, notificationChannels: [] }; + const updatedPolicy = { name: newIssuePolicyName, notificationChannels: [channelName] }; + + nock(cloudMonitoringOrigin()) + .get(`/v3/projects/${projectId}/notificationChannels`) + .query({ filter: expectedFilter }) + .reply(200, { notificationChannels: [emailChannel] }); + nock(crashlyticsApiOrigin()) + .post(`/v1alpha/projects/${projectId}/apps/${appId}:generateAlertPolicy`, { + alertType: AlertType.ALERT_TYPE_NEW_ISSUE, + }) + .reply(200, { alertPolicy: newIssuePolicyName }); + nock(cloudMonitoringOrigin()).get(`/v3/${newIssuePolicyName}`).reply(200, initialPolicy); + nock(cloudMonitoringOrigin()) + .patch(`/v3/${newIssuePolicyName}`, updatedPolicy) + .query({ updateMask: NOTIFICATION_CHANNELS_MASK_PATH }) + .reply(200, updatedPolicy); + + const res = await enableAlert(projectId, appId, AlertType.ALERT_TYPE_NEW_ISSUE, { + userEmail, + }); + + expect(res.channel).to.deep.equal(emailChannel); + expect(res.policy).to.deep.equal(updatedPolicy); + expect(nock.isDone()).to.be.true; + }); + + it("should skip updating policy if channelName is already attached", async () => { + const existingPolicy = { name: newIssuePolicyName, notificationChannels: [channelName] }; + + nock(crashlyticsApiOrigin()) + .post(`/v1alpha/projects/${projectId}/apps/${appId}:generateAlertPolicy`, { + alertType: AlertType.ALERT_TYPE_NEW_ISSUE, + }) + .reply(200, { alertPolicy: newIssuePolicyName }); + nock(cloudMonitoringOrigin()).get(`/v3/${newIssuePolicyName}`).reply(200, existingPolicy); + + const res = await enableAlert(projectId, appId, AlertType.ALERT_TYPE_NEW_ISSUE, { + channelName, + }); + + expect(res.policy).to.deep.equal(existingPolicy); + expect(nock.isDone()).to.be.true; + }); + + it("should throw a FirebaseError when neither channelName nor userEmail is specified", async () => { + await expect( + enableAlert(projectId, appId, AlertType.ALERT_TYPE_NEW_ISSUE, { userEmail: "" }), + ).to.be.rejectedWith( + FirebaseError, + "Either channelName or userEmail must be specified to enable a Crashlytics alert.", + ); + }); + + it("should throw a FirebaseError when resolved notification channel is missing a name", async () => { + const namelessChannel = { ...emailChannel, name: undefined }; + nock(cloudMonitoringOrigin()) + .get(`/v3/projects/${projectId}/notificationChannels`) + .query({ filter: expectedFilter }) + .reply(200, { notificationChannels: [namelessChannel] }); + + await expect( + enableAlert(projectId, appId, AlertType.ALERT_TYPE_NEW_ISSUE, { userEmail }), + ).to.be.rejectedWith( + FirebaseError, + "Resolved notification channel is missing a resource name.", + ); + expect(nock.isDone()).to.be.true; + }); + }); + + describe("enableAlerts", () => { + it("should return empty array without API calls when alertTypes is empty", async () => { + const res = await enableAlerts(projectId, appId, [], userEmail); + expect(res).to.deep.equal([]); + expect(nock.isDone()).to.be.true; + }); + + it("should throw a FirebaseError when resolved channel is missing a name", async () => { + const namelessChannel = { ...emailChannel, name: undefined }; + nock(cloudMonitoringOrigin()) + .get(`/v3/projects/${projectId}/notificationChannels`) + .query({ filter: expectedFilter }) + .reply(200, { notificationChannels: [namelessChannel] }); + + await expect( + enableAlerts(projectId, appId, [AlertType.ALERT_TYPE_NEW_ISSUE], userEmail), + ).to.be.rejectedWith( + FirebaseError, + "Notification channel for Crashlytics alerts is missing a name.", + ); + expect(nock.isDone()).to.be.true; + }); + + it("should resolve channel once and enable both new and regressed issue alerts", async () => { + const initialNewPolicy = { name: newIssuePolicyName, notificationChannels: [] }; + const updatedNewPolicy = { name: newIssuePolicyName, notificationChannels: [channelName] }; + const initialRegressedPolicy = { name: regressedIssuePolicyName, notificationChannels: [] }; + const updatedRegressedPolicy = { + name: regressedIssuePolicyName, + notificationChannels: [channelName], + }; + + nock(cloudMonitoringOrigin()) + .get(`/v3/projects/${projectId}/notificationChannels`) + .query({ filter: expectedFilter }) + .reply(200, {}); + nock(cloudMonitoringOrigin()) + .post(`/v3/projects/${projectId}/notificationChannels`) + .reply(200, emailChannel); + + nock(crashlyticsApiOrigin()) + .post(`/v1alpha/projects/${projectId}/apps/${appId}:generateAlertPolicy`, { + alertType: AlertType.ALERT_TYPE_NEW_ISSUE, + }) + .reply(200, { alertPolicy: newIssuePolicyName }); + nock(cloudMonitoringOrigin()).get(`/v3/${newIssuePolicyName}`).reply(200, initialNewPolicy); + nock(cloudMonitoringOrigin()) + .patch(`/v3/${newIssuePolicyName}`, updatedNewPolicy) + .query({ updateMask: NOTIFICATION_CHANNELS_MASK_PATH }) + .reply(200, updatedNewPolicy); + + nock(crashlyticsApiOrigin()) + .post(`/v1alpha/projects/${projectId}/apps/${appId}:generateAlertPolicy`, { + alertType: AlertType.ALERT_TYPE_REGRESSED_ISSUE, + }) + .reply(200, { alertPolicy: regressedIssuePolicyName }); + nock(cloudMonitoringOrigin()) + .get(`/v3/${regressedIssuePolicyName}`) + .reply(200, initialRegressedPolicy); + nock(cloudMonitoringOrigin()) + .patch(`/v3/${regressedIssuePolicyName}`, updatedRegressedPolicy) + .query({ updateMask: NOTIFICATION_CHANNELS_MASK_PATH }) + .reply(200, updatedRegressedPolicy); + + const res = await enableAlerts( + projectId, + appId, + [AlertType.ALERT_TYPE_NEW_ISSUE, AlertType.ALERT_TYPE_REGRESSED_ISSUE], + userEmail, + ); + + expect(res).to.deep.equal([updatedNewPolicy, updatedRegressedPolicy]); + expect(nock.isDone()).to.be.true; + }); + + it("should preserve fulfilled policies when one alert type fails", async () => { + const initialNewPolicy = { name: newIssuePolicyName, notificationChannels: [] }; + const updatedNewPolicy = { name: newIssuePolicyName, notificationChannels: [channelName] }; + + nock(cloudMonitoringOrigin()) + .get(`/v3/projects/${projectId}/notificationChannels`) + .query({ filter: expectedFilter }) + .reply(200, { notificationChannels: [emailChannel] }); + + nock(crashlyticsApiOrigin()) + .post(`/v1alpha/projects/${projectId}/apps/${appId}:generateAlertPolicy`, { + alertType: AlertType.ALERT_TYPE_NEW_ISSUE, + }) + .reply(200, { alertPolicy: newIssuePolicyName }); + nock(cloudMonitoringOrigin()).get(`/v3/${newIssuePolicyName}`).reply(200, initialNewPolicy); + nock(cloudMonitoringOrigin()) + .patch(`/v3/${newIssuePolicyName}`, updatedNewPolicy) + .query({ updateMask: NOTIFICATION_CHANNELS_MASK_PATH }) + .reply(200, updatedNewPolicy); + + nock(crashlyticsApiOrigin()) + .post(`/v1alpha/projects/${projectId}/apps/${appId}:generateAlertPolicy`, { + alertType: AlertType.ALERT_TYPE_REGRESSED_ISSUE, + }) + .reply(500, { error: "Internal Server Error" }); + + const res = await enableAlerts( + projectId, + appId, + [AlertType.ALERT_TYPE_NEW_ISSUE, AlertType.ALERT_TYPE_REGRESSED_ISSUE], + userEmail, + ); + + expect(res).to.deep.equal([updatedNewPolicy]); + expect(nock.isDone()).to.be.true; + }); + }); +}); diff --git a/src/crashlytics/alerts.ts b/src/crashlytics/alerts.ts new file mode 100644 index 00000000000..d22af3ceedd --- /dev/null +++ b/src/crashlytics/alerts.ts @@ -0,0 +1,209 @@ +import { FirebaseError, getErrMsg, getError, getErrStatus } from "../error"; +import { + AlertPolicy, + createNotificationChannel, + getAlertPolicy, + listNotificationChannels, + NotificationChannel, + updateAlertPolicy, +} from "../gcp/cloudmonitoring"; +import { logger } from "../logger"; +import { AlertType, GenerateAlertPolicyResponse } from "./types"; +import { CRASHLYTICS_API_CLIENT, TIMEOUT } from "./utils"; + +/** + * User label marking a notification channel as created by Firebase. + */ +export const FIREBASE_CHANNEL_LABEL = "is_firebase_channel"; + +/** + * Suffix appended to the email address to build a Firebase channel's display name. + */ +export const FIREBASE_EMAIL_CHANNEL_DISPLAY_NAME_SUFFIX = " - for Firebase alerts"; + +/** + * Cloud Monitoring notification channel type for email delivery. + */ +export const EMAIL_CHANNEL_TYPE = "email"; + +/** + * Field mask path for updating the notification channels list on an AlertPolicy. + */ +export const NOTIFICATION_CHANNELS_MASK_PATH = "notification_channels"; + +/** + * Generates an AlertPolicy in Cloud Monitoring via the Crashlytics Alert Policy service. + * @param projectId The GCP project ID or project number. + * @param appId The Firebase App ID. + * @param alertType The specific type of Crashlytics alert (e.g., ALERT_TYPE_NEW_ISSUE). + * @return The Cloud Monitoring AlertPolicy resource name. + */ +export async function generateAlertPolicy( + projectId: string, + appId: string, + alertType: AlertType, +): Promise { + logger.debug( + `[crashlytics] generateAlertPolicy called with projectId: ${projectId}, appId: ${appId}, alertType: ${alertType}`, + ); + try { + const response = await CRASHLYTICS_API_CLIENT.request< + { alertType: AlertType }, + GenerateAlertPolicyResponse + >({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + path: `/projects/${projectId}/apps/${appId}:generateAlertPolicy`, + body: { alertType }, + timeout: TIMEOUT, + }); + return response.body.alertPolicy; + } catch (err: unknown) { + const status = getErrStatus(err); + const msg = getErrMsg(err); + throw new FirebaseError( + `Failed to generate Crashlytics alert policy for ${alertType} on app ${appId} (status ${status}): ${msg}`, + { original: getError(err), status }, + ); + } +} + +/** + * Fetches the Firebase-owned notification channel for the user's email if it exists. + * Returns undefined if no such channel is configured yet. + */ +export async function fetchFirebaseEmailChannel( + projectId: string, + userEmail: string, +): Promise { + const escapedEmail = userEmail.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const filter = + `type = "${EMAIL_CHANNEL_TYPE}" AND ` + + `labels.email_address = "${escapedEmail}" AND ` + + `user_labels.${FIREBASE_CHANNEL_LABEL} = "true"`; + const channels = await listNotificationChannels(projectId, filter); + return channels[0]; +} + +/** + * Creates a new Firebase-owned email notification channel for the given user email. + */ +export async function createEmailChannel( + projectId: string, + userEmail: string, +): Promise { + return await createNotificationChannel(projectId, { + type: EMAIL_CHANNEL_TYPE, + displayName: `${userEmail}${FIREBASE_EMAIL_CHANNEL_DISPLAY_NAME_SUFFIX}`, + labels: { + email_address: userEmail, + }, + userLabels: { + [FIREBASE_CHANNEL_LABEL]: "true", + }, + }); +} + +/** + * Resolves the Firebase email notification channel for the given user email, + * reusing an existing channel if present or creating one otherwise. + */ +export async function resolveEmailNotificationChannel( + projectId: string, + userEmail: string, +): Promise { + const existingChannel = await fetchFirebaseEmailChannel(projectId, userEmail); + return existingChannel ?? (await createEmailChannel(projectId, userEmail)); +} + +/** + * Adds a notification channel resource name to an AlertPolicy if not already present. + */ +export function addChannelToPolicy(policy: AlertPolicy, channelName: string): AlertPolicy { + const currentChannels = policy.notificationChannels ?? []; + if (currentChannels.includes(channelName)) { + return policy; + } + return { + ...policy, + notificationChannels: [...currentChannels, channelName], + }; +} + +/** + * Generates (or resolves) the AlertPolicy for a Crashlytics alert type and attaches + * the specified notification channel (or resolves the channel for userEmail). + */ +export async function enableAlert( + projectId: string, + appId: string, + alertType: AlertType, + target: { channelName: string; userEmail?: never } | { channelName?: never; userEmail: string }, +): Promise<{ channel?: NotificationChannel; policy: AlertPolicy }> { + let channel: NotificationChannel | undefined; + if (!target.channelName && target.userEmail) { + channel = await resolveEmailNotificationChannel(projectId, target.userEmail); + } else if (!target.channelName) { + throw new FirebaseError( + "Either channelName or userEmail must be specified to enable a Crashlytics alert.", + { exit: 1 }, + ); + } + const channelName = target.channelName ?? channel?.name; + if (!channelName) { + throw new FirebaseError("Resolved notification channel is missing a resource name.", { + exit: 1, + }); + } + + const policyResourceName = await generateAlertPolicy(projectId, appId, alertType); + const targetPolicy = await getAlertPolicy(policyResourceName); + const policyWithChannel = addChannelToPolicy(targetPolicy, channelName); + const updatedPolicy = + policyWithChannel === targetPolicy + ? targetPolicy + : await updateAlertPolicy(policyWithChannel, NOTIFICATION_CHANNELS_MASK_PATH); + + return { channel, policy: updatedPolicy }; +} + +/** + * Enables one or more Crashlytics alert types for the given user email, + * resolving the Firebase email notification channel once and attaching it to each generated policy. + */ +export async function enableAlerts( + projectId: string, + appId: string, + alertTypes: AlertType[], + userEmail: string, +): Promise { + if (alertTypes.length === 0) { + return []; + } + const channel = await resolveEmailNotificationChannel(projectId, userEmail); + const channelName = channel.name; + if (!channelName) { + throw new FirebaseError("Notification channel for Crashlytics alerts is missing a name.", { + exit: 1, + }); + } + const results = await Promise.allSettled( + alertTypes.map(async (alertType) => { + const { policy } = await enableAlert(projectId, appId, alertType, { + channelName, + }); + return policy; + }), + ); + const policies: AlertPolicy[] = []; + for (const res of results) { + if (res.status === "fulfilled") { + policies.push(res.value); + } else { + logger.debug(`[crashlytics] Failed to enable alert: ${getErrMsg(res.reason)}`); + } + } + return policies; +} diff --git a/src/crashlytics/onboarding.spec.ts b/src/crashlytics/onboarding.spec.ts index 6a0edba86ed..7010c574618 100644 --- a/src/crashlytics/onboarding.spec.ts +++ b/src/crashlytics/onboarding.spec.ts @@ -3,14 +3,19 @@ import * as sinon from "sinon"; import nock from "../test/helpers/nock"; import * as onboarding from "./onboarding"; +import * as alerts from "./alerts"; import * as ensureApiEnabled from "../ensureApiEnabled"; +import * as experiments from "../experiments"; import * as cloudlogging from "../gcp/cloudlogging"; import * as cloudbilling from "../gcp/cloudbilling"; import * as firebasetelemetry from "./firebasetelemetry"; import * as apps from "../management/apps"; import * as apikeys from "../gcp/apikeys"; +import * as prompt from "../prompt"; +import * as requireAuth from "../requireAuth"; import * as utils from "../utils"; import { FirebaseError } from "../error"; +import { AlertType } from "./types"; describe("onboarding", () => { let ensureStub: sinon.SinonStub; @@ -21,6 +26,10 @@ describe("onboarding", () => { let getAppConfigStub: sinon.SinonStub; let updateAppApiKeyRestrictionStub: sinon.SinonStub; let logLabeledWarningStub: sinon.SinonStub; + let checkboxStub: sinon.SinonStub; + let enableAlertsStub: sinon.SinonStub; + let requireAuthStub: sinon.SinonStub; + let isEnabledStub: sinon.SinonStub; before(() => { nock.disableNetConnect(); @@ -55,6 +64,18 @@ describe("onboarding", () => { }); updateAppApiKeyRestrictionStub = sinon.stub(apikeys, "updateAppApiKeyRestriction").resolves(); logLabeledWarningStub = sinon.stub(utils, "logLabeledWarning"); + checkboxStub = sinon + .stub(prompt, "checkbox") + .resolves([AlertType.ALERT_TYPE_NEW_ISSUE, AlertType.ALERT_TYPE_REGRESSED_ISSUE]); + enableAlertsStub = sinon.stub(alerts, "enableAlerts").resolves([ + { name: "projects/test-project/alertPolicies/111", notificationChannels: ["ch-1"] }, + { name: "projects/test-project/alertPolicies/222", notificationChannels: ["ch-1"] }, + ]); + requireAuthStub = sinon.stub(requireAuth, "requireAuth").resolves("user@example.com"); + isEnabledStub = sinon + .stub(experiments, "isEnabled") + .withArgs("crashlyticsWebAlerts") + .returns(true); }); afterEach(() => { @@ -62,7 +83,7 @@ describe("onboarding", () => { sinon.restore(); }); - it("should successfully onboard web app", async () => { + it("should successfully onboard web app and enable selected alerts", async () => { const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); expect(ensureStub).to.have.been.calledTwice; @@ -83,6 +104,98 @@ describe("onboarding", () => { apiKey: "fake-api-key-123", service: onboarding.CRASHLYTICS_TELEMETRY_SERVICE, }); + expect(checkboxStub).to.have.been.calledOnce; + expect(enableAlertsStub).to.have.been.calledOnceWith( + "test-project", + "1:123:web:456", + [AlertType.ALERT_TYPE_NEW_ISSUE, AlertType.ALERT_TYPE_REGRESSED_ISSUE], + "user@example.com", + ); + expect(res.config.enablementState).to.equal("ENABLED"); + expect(res.alertPolicies).to.have.lengthOf(2); + }); + + it("should use options.user.email directly when present without calling requireAuth", async () => { + const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456", { + user: { email: "cli-user@example.com" }, + }); + + expect(requireAuthStub).to.not.have.been.called; + expect(enableAlertsStub).to.have.been.calledOnceWith( + "test-project", + "1:123:web:456", + [AlertType.ALERT_TYPE_NEW_ISSUE, AlertType.ALERT_TYPE_REGRESSED_ISSUE], + "cli-user@example.com", + ); + expect(res.alertPolicies).to.have.lengthOf(2); + }); + + it("should skip enabling alerts if user deselects all alert options", async () => { + checkboxStub.resolves([]); + + const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); + + expect(checkboxStub).to.have.been.calledOnce; + expect(enableAlertsStub).to.not.have.been.called; + expect(res.alertPolicies).to.be.undefined; + }); + + it("should skip alerting prompt and setup when crashlyticsWebAlerts experiment is disabled", async () => { + isEnabledStub.withArgs("crashlyticsWebAlerts").returns(false); + + const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); + + expect(checkboxStub).to.not.have.been.called; + expect(enableAlertsStub).to.not.have.been.called; + expect(res.alertPolicies).to.be.undefined; + }); + + it("should skip alerting prompt and setup in non-interactive mode", async () => { + const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456", { + nonInteractive: true, + }); + + expect(checkboxStub).to.not.have.been.called; + expect(enableAlertsStub).to.not.have.been.called; + expect(res.alertPolicies).to.be.undefined; + }); + + it("should log a warning and skip prompt if authenticated user email cannot be determined for alerts", async () => { + requireAuthStub.resolves(null); + + const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); + + expect(checkboxStub).to.not.have.been.called; + expect(enableAlertsStub).to.not.have.been.called; + expect(logLabeledWarningStub).to.have.been.calledWith( + "crashlytics", + "Unable to determine authenticated user email for alert setup. You can configure alerts later in the Firebase Console.", + ); + expect(res.config.enablementState).to.equal("ENABLED"); + }); + + it("should log a warning and skip prompt if requireAuth throws an error", async () => { + requireAuthStub.rejects(new FirebaseError("Auth failed")); + + const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); + + expect(checkboxStub).to.not.have.been.called; + expect(enableAlertsStub).to.not.have.been.called; + expect(logLabeledWarningStub).to.have.been.calledWith( + "crashlytics", + "Unable to determine authenticated user email for alert setup. You can configure alerts later in the Firebase Console.", + ); + expect(res.config.enablementState).to.equal("ENABLED"); + }); + + it("should log a warning and still succeed if enableAlerts throws an error", async () => { + const alertError = new FirebaseError("Failed to generate alert policy"); + enableAlertsStub.rejects(alertError); + + const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); + + expect(enableAlertsStub).to.have.been.calledOnce; + expect(logLabeledWarningStub).to.have.been.calledWith("crashlytics", alertError.message); expect(res.config.enablementState).to.equal("ENABLED"); }); diff --git a/src/crashlytics/onboarding.ts b/src/crashlytics/onboarding.ts index 7e682149c93..63e8e5cd250 100644 --- a/src/crashlytics/onboarding.ts +++ b/src/crashlytics/onboarding.ts @@ -1,5 +1,6 @@ import { ensure } from "../ensureApiEnabled"; -import { FirebaseError } from "../error"; +import { FirebaseError, getErrMsg } from "../error"; +import * as experiments from "../experiments"; import { checkBillingEnabled, enableBilling } from "../gcp/cloudbilling"; import { createOrUpdateLogBucket, @@ -7,10 +8,16 @@ import { LogBucket, LogSink, } from "../gcp/cloudlogging"; +import { AlertPolicy } from "../gcp/cloudmonitoring"; +import { enableAlerts } from "./alerts"; import { createOrUpdateTelemetryConfig, TelemetryConfig } from "./firebasetelemetry"; +import { AlertType } from "./types"; import { logLabeledBullet, logLabeledSuccess, logLabeledWarning } from "../utils"; import { updateAppApiKeyRestriction } from "../gcp/apikeys"; import { AppPlatform, getAppConfig } from "../management/apps"; +import { logger } from "../logger"; +import { checkbox } from "../prompt"; +import { requireAuth } from "../requireAuth"; export const CRASHLYTICS_TELEMETRY_BUCKET_ID = "firebase-telemetry"; export const CRASHLYTICS_TELEMETRY_SINK_ID = "firebase-telemetry-routing"; @@ -21,16 +28,37 @@ export interface OnboardWebResult { bucket: LogBucket; sink: LogSink; config: TelemetryConfig; + alertPolicies?: AlertPolicy[]; +} + +export interface OnboardWebOptions { + nonInteractive?: boolean; + user?: { email?: string }; +} + +async function resolveAuthenticatedUserEmail( + options: OnboardWebOptions, +): Promise { + if (options.user?.email) { + return options.user.email; + } + try { + return (await requireAuth(options)) ?? undefined; + } catch (err: unknown) { + logger.debug(`[crashlytics] Failed to resolve authenticated user email: ${getErrMsg(err)}`); + return undefined; + } } /** * Onboards a Firebase Web App to Crashlytics by enabling required APIs, - * setting up Cloud Logging bucket and sink routing, and creating a Telemetry Config. + * setting up Cloud Logging bucket and sink routing, creating a Telemetry Config, + * and optionally configuring Crashlytics email alerts. */ export async function onboardCrashlyticsWeb( projectId: string, appId: string, - options: { nonInteractive?: boolean } = {}, + options: OnboardWebOptions = {}, ): Promise { const billingEnabled = await checkBillingEnabled(projectId); if (!billingEnabled && options.nonInteractive) { @@ -107,5 +135,46 @@ export async function onboardCrashlyticsWeb( ); logLabeledSuccess("crashlytics", "Crashlytics telemetry configured successfully."); - return { bucket, sink, config }; + if (!experiments.isEnabled("crashlyticsWebAlerts") || options.nonInteractive) { + return { bucket, sink, config }; + } + + const userEmail = await resolveAuthenticatedUserEmail(options); + if (!userEmail) { + logLabeledWarning( + "crashlytics", + "Unable to determine authenticated user email for alert setup. You can configure alerts later in the Firebase Console.", + ); + return { bucket, sink, config }; + } + + const selectedAlerts = await checkbox({ + message: "Which email alerts would you like to enable? (Optional)", + choices: [ + { + name: "New issues (Notify when a new issue is detected)", + value: AlertType.ALERT_TYPE_NEW_ISSUE, + checked: true, + }, + { + name: "Regressed issues (Notify when a closed issue reoccurs)", + value: AlertType.ALERT_TYPE_REGRESSED_ISSUE, + checked: true, + }, + ], + }); + + if (selectedAlerts.length === 0) { + return { bucket, sink, config }; + } + + logLabeledBullet("crashlytics", `Setting up Crashlytics email alerts for ${userEmail}...`); + try { + const alertPolicies = await enableAlerts(projectId, appId, selectedAlerts, userEmail); + logLabeledSuccess("crashlytics", "Crashlytics email alerts configured successfully."); + return { bucket, sink, config, alertPolicies }; + } catch (err: unknown) { + logLabeledWarning("crashlytics", getErrMsg(err)); + return { bucket, sink, config }; + } } diff --git a/src/crashlytics/types.ts b/src/crashlytics/types.ts index eb4758b48d4..b19363303c3 100644 --- a/src/crashlytics/types.ts +++ b/src/crashlytics/types.ts @@ -740,3 +740,26 @@ export interface BrowserFilter { */ displayNames: string[]; } + +/** Supported alert types for template generation. */ +export type AlertType = + | "ALERT_TYPE_UNSPECIFIED" + | "ALERT_TYPE_NEW_ISSUE" + | "ALERT_TYPE_REGRESSED_ISSUE"; + +export const AlertType = { + ALERT_TYPE_UNSPECIFIED: "ALERT_TYPE_UNSPECIFIED", + /** Alert for newly detected issues. */ + ALERT_TYPE_NEW_ISSUE: "ALERT_TYPE_NEW_ISSUE", + /** Alert for regressed (reopened) issues. */ + ALERT_TYPE_REGRESSED_ISSUE: "ALERT_TYPE_REGRESSED_ISSUE", +} as const; + +/** Response message for the GenerateAlertPolicy method. */ +export interface GenerateAlertPolicyResponse { + /** + * The created Cloud Monitoring AlertPolicy resource name. + * Format: "projects/{project}/alertPolicies/{alert_policy_id}". + */ + alertPolicy: string; +} diff --git a/src/experiments.ts b/src/experiments.ts index 5558280875f..2ee3cb262a2 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -238,6 +238,11 @@ export const ALL_EXPERIMENTS = experiments({ default: false, public: true, }, + crashlyticsWebAlerts: { + shortDescription: "Enable configuring Crashlytics email alerts during web app onboarding.", + default: false, + public: false, + }, secretEnvParams: { shortDescription: "Enable reading the backing resource binding for a Functions secret param from .env", diff --git a/src/gcp/cloudmonitoring.spec.ts b/src/gcp/cloudmonitoring.spec.ts index 6638c4bb7a3..b3bc397791f 100644 --- a/src/gcp/cloudmonitoring.spec.ts +++ b/src/gcp/cloudmonitoring.spec.ts @@ -2,10 +2,22 @@ import { expect } from "chai"; import nock from "../test/helpers/nock"; import * as api from "../api"; import { FirebaseError } from "../error"; -import { Aligner, CmQuery, queryTimeSeries, TimeSeriesView } from "./cloudmonitoring"; +import { + AlertPolicy, + Aligner, + CmQuery, + createNotificationChannel, + getAlertPolicy, + listNotificationChannels, + NotificationChannel, + queryTimeSeries, + TimeSeriesView, + updateAlertPolicy, +} from "./cloudmonitoring"; const CLOUD_MONITORING_VERSION = "v3"; const PROJECT_NUMBER = 1; +const PROJECT_ID = "test-project"; describe("queryTimeSeries", () => { afterEach(() => { @@ -46,3 +58,144 @@ describe("queryTimeSeries", () => { expect(nock.isDone()).to.be.true; }); }); + +describe("notificationChannels", () => { + afterEach(() => { + nock.cleanAll(); + }); + + const channel: NotificationChannel = { + name: `projects/${PROJECT_ID}/notificationChannels/12345`, + type: "email", + displayName: "user@example.com - for Firebase alerts", + labels: { email_address: "user@example.com" }, + userLabels: { is_firebase_channel: "true" }, + }; + + it("listNotificationChannels should return channels matching filter", async () => { + const filter = + 'type = "email" AND labels.email_address = "user@example.com" AND user_labels.is_firebase_channel = "true"'; + nock(api.cloudMonitoringOrigin()) + .get(`/${CLOUD_MONITORING_VERSION}/projects/${PROJECT_ID}/notificationChannels`) + .query({ filter }) + .reply(200, { notificationChannels: [channel] }); + + const res = await listNotificationChannels(PROJECT_ID, filter); + expect(res).to.deep.equal([channel]); + expect(nock.isDone()).to.be.true; + }); + + it("listNotificationChannels should return empty array when no channels exist", async () => { + nock(api.cloudMonitoringOrigin()) + .get(`/${CLOUD_MONITORING_VERSION}/projects/${PROJECT_ID}/notificationChannels`) + .reply(200, {}); + + const res = await listNotificationChannels(PROJECT_ID); + expect(res).to.deep.equal([]); + expect(nock.isDone()).to.be.true; + }); + + it("createNotificationChannel should create and return a channel", async () => { + const input = { + type: "email", + displayName: "user@example.com - for Firebase alerts", + labels: { email_address: "user@example.com" }, + userLabels: { is_firebase_channel: "true" }, + }; + nock(api.cloudMonitoringOrigin()) + .post(`/${CLOUD_MONITORING_VERSION}/projects/${PROJECT_ID}/notificationChannels`, input) + .reply(200, channel); + + const res = await createNotificationChannel(PROJECT_ID, input); + expect(res).to.deep.equal(channel); + expect(nock.isDone()).to.be.true; + }); + + it("listNotificationChannels should throw a FirebaseError on API error", async () => { + nock(api.cloudMonitoringOrigin()) + .get(`/${CLOUD_MONITORING_VERSION}/projects/${PROJECT_ID}/notificationChannels`) + .reply(500, { error: "Internal Server Error" }); + + await expect(listNotificationChannels(PROJECT_ID)).to.be.rejectedWith( + FirebaseError, + "Failed to list Cloud Monitoring notification channels", + ); + expect(nock.isDone()).to.be.true; + }); + + it("createNotificationChannel should throw a FirebaseError on API error", async () => { + nock(api.cloudMonitoringOrigin()) + .post(`/${CLOUD_MONITORING_VERSION}/projects/${PROJECT_ID}/notificationChannels`) + .reply(500, { error: "Internal Server Error" }); + + await expect(createNotificationChannel(PROJECT_ID, { type: "email" })).to.be.rejectedWith( + FirebaseError, + "Failed to create Cloud Monitoring notification channel", + ); + expect(nock.isDone()).to.be.true; + }); +}); + +describe("alertPolicies", () => { + afterEach(() => { + nock.cleanAll(); + }); + + const policyName = `projects/${PROJECT_ID}/alertPolicies/67890`; + const policy: AlertPolicy = { + name: policyName, + displayName: "New Crashlytics issue", + notificationChannels: [], + }; + + it("getAlertPolicy should retrieve an alert policy by resource name", async () => { + nock(api.cloudMonitoringOrigin()) + .get(`/${CLOUD_MONITORING_VERSION}/${policyName}`) + .reply(200, policy); + + const res = await getAlertPolicy(policyName); + expect(res).to.deep.equal(policy); + expect(nock.isDone()).to.be.true; + }); + + it("getAlertPolicy should throw a FirebaseError on API error", async () => { + nock(api.cloudMonitoringOrigin()) + .get(`/${CLOUD_MONITORING_VERSION}/${policyName}`) + .reply(404, { error: "Not Found" }); + + await expect(getAlertPolicy(policyName)).to.be.rejectedWith( + FirebaseError, + `Failed to get Cloud Monitoring alert policy ${policyName}`, + ); + expect(nock.isDone()).to.be.true; + }); + + it("updateAlertPolicy should patch an alert policy with updateMask", async () => { + const updatedPolicy = { + name: policyName, + displayName: "New Crashlytics issue", + notificationChannels: [`projects/${PROJECT_ID}/notificationChannels/12345`], + }; + nock(api.cloudMonitoringOrigin()) + .patch(`/${CLOUD_MONITORING_VERSION}/${policyName}`, updatedPolicy) + .query({ updateMask: "notification_channels" }) + .reply(200, updatedPolicy); + + const res = await updateAlertPolicy(updatedPolicy, "notification_channels"); + expect(res).to.deep.equal(updatedPolicy); + expect(nock.isDone()).to.be.true; + }); + + it("updateAlertPolicy should throw a FirebaseError on API error", async () => { + nock(api.cloudMonitoringOrigin()) + .patch(`/${CLOUD_MONITORING_VERSION}/${policyName}`) + .query({ updateMask: "notification_channels" }) + .reply(500, { error: "Internal Server Error" }); + + await expect(updateAlertPolicy(policy, "notification_channels")).to.be.rejectedWith( + FirebaseError, + `Failed to update Cloud Monitoring alert policy ${policyName}`, + ); + expect(nock.isDone()).to.be.true; + }); +}); diff --git a/src/gcp/cloudmonitoring.ts b/src/gcp/cloudmonitoring.ts index 6e4d4095ec6..d36a2e046a9 100644 --- a/src/gcp/cloudmonitoring.ts +++ b/src/gcp/cloudmonitoring.ts @@ -1,6 +1,6 @@ import { cloudMonitoringOrigin } from "../api"; import { Client } from "../apiv2"; -import { FirebaseError } from "../error"; +import { FirebaseError, getErrMsg, getError, getErrStatus } from "../error"; export const CLOUD_MONITORING_VERSION = "v3"; @@ -139,17 +139,154 @@ export async function queryTimeSeries( urlPrefix: cloudMonitoringOrigin(), apiVersion: CLOUD_MONITORING_VERSION, }); + const queryParams: Record = {}; + for (const [key, value] of Object.entries(query)) { + if (typeof value === "string" || typeof value === "number") { + queryParams[key] = value; + } + } try { const res = await client.get<{ timeSeries: TimeSeriesResponse }>( `/projects/${project}/timeSeries/`, { - queryParams: query as { [key: string]: any }, + queryParams, }, ); return res.body.timeSeries; - } catch (err: any) { - throw new FirebaseError(`Failed to get Cloud Monitoring metric: ${err}`, { - status: err.status, + } catch (err: unknown) { + throw new FirebaseError(`Failed to get Cloud Monitoring metric: ${getErrMsg(err)}`, { + status: getErrStatus(err), + original: getError(err), }); } } + +/** NotificationChannel from v3 Cloud Monitoring API */ +export interface NotificationChannel { + name?: string; + type: string; + displayName?: string; + description?: string; + labels?: Record; + userLabels?: Record; + enabled?: boolean; +} + +/** AlertPolicy from v3 Cloud Monitoring API */ +export interface AlertPolicy { + name: string; + displayName?: string; + notificationChannels?: string[]; + userLabels?: Record; + enabled?: boolean; +} + +/** + * Lists Cloud Monitoring NotificationChannel resources for a project. + */ +export async function listNotificationChannels( + project: number | string, + filter?: string, +): Promise { + const client = new Client({ + urlPrefix: cloudMonitoringOrigin(), + apiVersion: CLOUD_MONITORING_VERSION, + }); + try { + const res = await client.get<{ notificationChannels?: NotificationChannel[] }>( + `/projects/${project}/notificationChannels`, + filter ? { queryParams: { filter } } : {}, + ); + return res.body?.notificationChannels ?? []; + } catch (err: unknown) { + throw new FirebaseError( + `Failed to list Cloud Monitoring notification channels: ${getErrMsg(err)}`, + { + status: getErrStatus(err), + original: getError(err), + }, + ); + } +} + +/** + * Creates a Cloud Monitoring NotificationChannel resource for a project. + */ +export async function createNotificationChannel( + project: number | string, + channel: NotificationChannel, +): Promise { + const client = new Client({ + urlPrefix: cloudMonitoringOrigin(), + apiVersion: CLOUD_MONITORING_VERSION, + }); + try { + const res = await client.post( + `/projects/${project}/notificationChannels`, + channel, + ); + return res.body; + } catch (err: unknown) { + throw new FirebaseError( + `Failed to create Cloud Monitoring notification channel: ${getErrMsg(err)}`, + { + status: getErrStatus(err), + original: getError(err), + }, + ); + } +} + +/** + * Gets a Cloud Monitoring AlertPolicy resource by its full resource name + * (e.g., `projects/{project}/alertPolicies/{policyId}`). + */ +export async function getAlertPolicy(name: string): Promise { + const client = new Client({ + urlPrefix: cloudMonitoringOrigin(), + apiVersion: CLOUD_MONITORING_VERSION, + }); + const path = name.startsWith("/") ? name : `/${name}`; + try { + const res = await client.get(path); + return res.body; + } catch (err: unknown) { + throw new FirebaseError( + `Failed to get Cloud Monitoring alert policy ${name}: ${getErrMsg(err)}`, + { + status: getErrStatus(err), + original: getError(err), + }, + ); + } +} + +/** + * Updates a Cloud Monitoring AlertPolicy resource. + */ +export async function updateAlertPolicy( + policy: AlertPolicy, + updateMask?: string, +): Promise { + const client = new Client({ + urlPrefix: cloudMonitoringOrigin(), + apiVersion: CLOUD_MONITORING_VERSION, + }); + const path = policy.name.startsWith("/") ? policy.name : `/${policy.name}`; + try { + const res = await client.patch( + path, + policy, + updateMask ? { queryParams: { updateMask } } : {}, + ); + return res.body; + } catch (err: unknown) { + throw new FirebaseError( + `Failed to update Cloud Monitoring alert policy ${policy.name}: ${getErrMsg(err)}`, + { + status: getErrStatus(err), + original: getError(err), + }, + ); + } +}