diff --git a/src/commands/ext-install.ts b/src/commands/ext-install.ts index 8627cd388f2..cd6c3a506fa 100644 --- a/src/commands/ext-install.ts +++ b/src/commands/ext-install.ts @@ -14,6 +14,7 @@ import { Command } from "../command"; import { FirebaseError } from "../error"; import * as getProjectId from "../getProjectId"; import * as extensionsApi from "../extensions/extensionsApi"; +import * as provisioningHelper from "../extensions/provisioningHelper"; import { displayWarningPrompts } from "../extensions/warnings"; import * as paramHelper from "../extensions/paramHelper"; import { @@ -57,6 +58,8 @@ async function installExtension(options: InstallExtensionOptions): Promise "Installing your extension instance. This usually takes 3 to 5 minutes..." ); try { + await provisioningHelper.checkProductsProvisioned(projectId, spec); + if (spec.billingRequired) { const enabled = await checkBillingEnabled(projectId); if (!enabled) { diff --git a/src/commands/ext-update.ts b/src/commands/ext-update.ts index a3a5fdac9e1..062eedcb250 100644 --- a/src/commands/ext-update.ts +++ b/src/commands/ext-update.ts @@ -12,6 +12,7 @@ import { displayNode10UpdateBillingNotice } from "../extensions/billingMigration import { enableBilling } from "../extensions/checkProjectBilling"; import { checkBillingEnabled } from "../gcp/cloudbilling"; import * as extensionsApi from "../extensions/extensionsApi"; +import * as provisioningHelper from "../extensions/provisioningHelper"; import { ensureExtensionsApiEnabled, logPrefix, @@ -232,6 +233,9 @@ export default new Command("ext:update [updateSource]") newSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION || newSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION_VERSION; await displayChanges(existingSpec, newSpec, isOfficial); + + await provisioningHelper.checkProductsProvisioned(projectId, newSpec); + if (newSpec.billingRequired) { const enabled = await checkBillingEnabled(projectId); if (!enabled) { diff --git a/src/extensions/provisioningHelper.ts b/src/extensions/provisioningHelper.ts new file mode 100644 index 00000000000..38f2753b4a8 --- /dev/null +++ b/src/extensions/provisioningHelper.ts @@ -0,0 +1,116 @@ +import * as marked from "marked"; + +import * as extensionsApi from "./extensionsApi"; +import * as api from "../api"; +import { FirebaseError } from "../error"; + +/** Product for which provisioning can be (or is) deferred */ +export enum DeferredProduct { + STORAGE, + AUTH, +} + +/** + * Checks whether products used by the extension require provisioning. + * + * @param spec extension spec + */ +export async function checkProductsProvisioned( + projectId: string, + spec: extensionsApi.ExtensionSpec +): Promise { + const usedProducts = getUsedProducts(spec); + const needProvisioning = [] as DeferredProduct[]; + let isStorageProvisionedPromise; + let isAuthProvisionedPromise; + if (usedProducts.includes(DeferredProduct.STORAGE)) { + isStorageProvisionedPromise = isStorageProvisioned(projectId); + } + if (usedProducts.includes(DeferredProduct.AUTH)) { + isAuthProvisionedPromise = isAuthProvisioned(projectId); + } + + if (isStorageProvisionedPromise && !(await isStorageProvisionedPromise)) { + needProvisioning.push(DeferredProduct.STORAGE); + } + if (isAuthProvisionedPromise && !(await isAuthProvisionedPromise)) { + needProvisioning.push(DeferredProduct.AUTH); + } + + if (needProvisioning.length > 0) { + let errorMessage = + "Some services used by this extension have not been set up on your " + + "Firebase project. To ensure this extension works as intended, you must enable these " + + "services by following the provided links, then retry installing the extension\n\n"; + if (needProvisioning.includes(DeferredProduct.STORAGE)) { + errorMessage += + " - Firebase Storage: store and retrieve user-generated files like images, audio, and " + + "video without server-side code.\n"; + errorMessage += ` https://console.firebase.google.com/project/${projectId}/storage`; + errorMessage += "\n"; + } + if (needProvisioning.includes(DeferredProduct.AUTH)) { + errorMessage += + " - Firebase Authentication: authenticate and manage users from a variety of providers " + + "without server-side code.\n"; + errorMessage += ` https://console.firebase.google.com/project/${projectId}/authentication/users`; + } + throw new FirebaseError(marked(errorMessage), { exit: 2 }); + } +} + +/** + * From the spec determines which products are used by the extension and + * returns the list. + */ +export function getUsedProducts(spec: extensionsApi.ExtensionSpec): DeferredProduct[] { + const usedProducts: DeferredProduct[] = []; + const usedApis = spec.apis?.map((api) => api.apiName); + const usedRoles = spec.roles?.map((r) => r.role.split(".")[0]); + const usedTriggers = spec.resources.map((r) => getTriggerType(r.propertiesYaml)); + if ( + usedApis?.includes("storage-component.googleapis.com") || + usedRoles?.includes("storage") || + usedTriggers.find((t) => t?.startsWith("google.storage.")) + ) { + usedProducts.push(DeferredProduct.STORAGE); + } + if ( + usedApis?.includes("identitytoolkit.googleapis.com") || + usedRoles?.includes("firebaseauth") || + usedTriggers.find((t) => t?.startsWith("providers/firebase.auth/")) + ) { + usedProducts.push(DeferredProduct.AUTH); + } + return usedProducts; +} + +/** + * Parses out trigger eventType from the propertiesYaml. + */ +function getTriggerType(propertiesYaml: string | undefined) { + return propertiesYaml?.match(/eventType:\ ([\S]+)/)?.[1]; +} + +async function isStorageProvisioned(projectId: string): Promise { + const resp = await api.request("GET", `/v1beta/projects/${projectId}/buckets`, { + auth: true, + origin: api.firebaseStorageOrigin, + }); + return !!resp.body?.buckets?.find((bucket: any) => { + const bucketResourceName = bucket.name; + // Bucket resource name looks like: projects/PROJECT_NUMBER/buckets/BUCKET_NAME + // and we just need the BUCKET_NAME part. + const bucketResourceNameTokens = bucketResourceName.split("/"); + const pattern = "^" + projectId + "(.[[a-z0-9]+)*.appspot.com$"; + return new RegExp(pattern).test(bucketResourceNameTokens[bucketResourceNameTokens.length - 1]); + }); +} + +async function isAuthProvisioned(projectId: string): Promise { + const resp = await api.request("GET", `/v1/projects/${projectId}/products`, { + auth: true, + origin: api.firedataOrigin, + }); + return !!resp.body?.activation?.map((a: any) => a.service).includes("FIREBASE_AUTH"); +} diff --git a/src/test/extensions/provisioningHelper.spec.ts b/src/test/extensions/provisioningHelper.spec.ts new file mode 100644 index 00000000000..928d242575b --- /dev/null +++ b/src/test/extensions/provisioningHelper.spec.ts @@ -0,0 +1,207 @@ +import * as nock from "nock"; +import { expect } from "chai"; + +import * as api from "../../api"; +import * as provisioningHelper from "../../extensions/provisioningHelper"; +import * as extensionsApi from "../../extensions/extensionsApi"; +import { FirebaseError } from "../../error"; + +const TEST_INSTANCES_RESPONSE = {}; +const PROJECT_ID = "test-project"; +const SPEC_WITH_STORAGE_AND_AUTH = { + apis: [ + { + apiName: "storage-component.googleapis.com", + }, + { + apiName: "identitytoolkit.googleapis.com", + }, + ] as extensionsApi.Api[], + resources: [] as extensionsApi.Resource[], +} as extensionsApi.ExtensionSpec; + +const FIREDATA_AUTH_ACTIVATED_RESPONSE = { + activation: [ + { + service: "FIREBASE_AUTH", + }, + ], +}; + +const FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE = { + buckets: [ + { + name: `projects/12345/bucket/${PROJECT_ID}.appspot.com`, + }, + ], +}; + +describe("provisioningHelper", () => { + afterEach(() => { + nock.cleanAll(); + }); + + describe("getUsedProducts", () => { + let testSpec: extensionsApi.ExtensionSpec; + + beforeEach(() => { + testSpec = { + apis: [ + { + apiName: "unrelated.googleapis.com", + }, + ] as extensionsApi.Api[], + roles: [ + { + role: "unrelated.role", + }, + ] as extensionsApi.Role[], + resources: [ + { + propertiesYaml: + "availableMemoryMb: 1024\neventTrigger:\n eventType: providers/unrelates.service/eventTypes/something.do\n resource: projects/_/buckets/${param:IMG_BUCKET}\nlocation: ${param:LOCATION}\nruntime: nodejs10\n", + }, + ] as extensionsApi.Resource[], + } as extensionsApi.ExtensionSpec; + }); + + it("returns empty array when nothing is used", () => { + expect(provisioningHelper.getUsedProducts(testSpec)).to.be.empty; + }); + + it("returns STORAGE when Storage API is used", () => { + testSpec.apis?.push({ + apiName: "storage-component.googleapis.com", + reason: "whatever", + }); + expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([ + provisioningHelper.DeferredProduct.STORAGE, + ]); + }); + + it("returns STORAGE when Storage Role is used", () => { + testSpec.roles?.push({ + role: "storage.object.admin", + reason: "whatever", + }); + expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([ + provisioningHelper.DeferredProduct.STORAGE, + ]); + }); + + it("returns STORAGE when Storage trigger is used", () => { + testSpec.resources?.push({ + propertiesYaml: + "availableMemoryMb: 1024\neventTrigger:\n eventType: google.storage.object.finalize\n resource: projects/_/buckets/${param:IMG_BUCKET}\nlocation: ${param:LOCATION}\nruntime: nodejs10\n", + } as extensionsApi.Resource); + expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([ + provisioningHelper.DeferredProduct.STORAGE, + ]); + }); + + it("returns AUTH when Authentication API is used", () => { + testSpec.apis?.push({ + apiName: "identitytoolkit.googleapis.com", + reason: "whatever", + }); + expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([ + provisioningHelper.DeferredProduct.AUTH, + ]); + }); + + it("returns AUTH when Authentication Role is used", () => { + testSpec.roles?.push({ + role: "firebaseauth.user.admin", + reason: "whatever", + }); + expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([ + provisioningHelper.DeferredProduct.AUTH, + ]); + }); + + it("returns AUTH when Auth trigger is used", () => { + testSpec.resources?.push({ + propertiesYaml: + "availableMemoryMb: 1024\neventTrigger:\n eventType: providers/firebase.auth/eventTypes/user.create\n resource: projects/_/buckets/${param:IMG_BUCKET}\nlocation: ${param:LOCATION}\nruntime: nodejs10\n", + } as extensionsApi.Resource); + expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([ + provisioningHelper.DeferredProduct.AUTH, + ]); + }); + }); + + describe("checkProductsProvisioned", () => { + it("passes provisioning check status when nothing is used", async () => { + await expect( + provisioningHelper.checkProductsProvisioned(PROJECT_ID, { + resources: [] as extensionsApi.Resource[], + } as extensionsApi.ExtensionSpec) + ).to.be.fulfilled; + }); + + it("passes provisioning check when all is provisioned", async () => { + nock(api.firedataOrigin) + .get(`/v1/projects/${PROJECT_ID}/products`) + .reply(200, FIREDATA_AUTH_ACTIVATED_RESPONSE); + nock(api.firebaseStorageOrigin) + .get(`/v1beta/projects/${PROJECT_ID}/buckets`) + .reply(200, FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE); + + await expect( + provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH) + ).to.be.fulfilled; + + expect(nock.isDone()).to.be.true; + }); + + it("fails provisioning check storage when default bucket is not linked", async () => { + nock(api.firedataOrigin) + .get(`/v1/projects/${PROJECT_ID}/products`) + .reply(200, FIREDATA_AUTH_ACTIVATED_RESPONSE); + nock(api.firebaseStorageOrigin) + .get(`/v1beta/projects/${PROJECT_ID}/buckets`) + .reply(200, { + buckets: [ + { + name: `projects/12345/bucket/some-other-bucket`, + }, + ], + }); + + await expect( + provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH) + ).to.be.rejectedWith(FirebaseError, "Firebase Storage: store and retrieve user-generated"); + + expect(nock.isDone()).to.be.true; + }); + + it("fails provisioning check storage when no firebase storage buckets", async () => { + nock(api.firedataOrigin) + .get(`/v1/projects/${PROJECT_ID}/products`) + .reply(200, FIREDATA_AUTH_ACTIVATED_RESPONSE); + nock(api.firebaseStorageOrigin).get(`/v1beta/projects/${PROJECT_ID}/buckets`).reply(200, {}); + + await expect( + provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH) + ).to.be.rejectedWith(FirebaseError, "Firebase Storage: store and retrieve user-generated"); + + expect(nock.isDone()).to.be.true; + }); + + it("fails provisioning check storage when no auth is not provisioned", async () => { + nock(api.firedataOrigin).get(`/v1/projects/${PROJECT_ID}/products`).reply(200, {}); + nock(api.firebaseStorageOrigin) + .get(`/v1beta/projects/${PROJECT_ID}/buckets`) + .reply(200, FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE); + + await expect( + provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH) + ).to.be.rejectedWith( + FirebaseError, + "Firebase Authentication: authenticate and manage users from" + ); + + expect(nock.isDone()).to.be.true; + }); + }); +});