From f5e4e956a0c9daf4cf1b7209e5a40c99a54194b2 Mon Sep 17 00:00:00 2001 From: PavelJ Date: Tue, 15 Jun 2021 17:19:17 -0400 Subject: [PATCH 1/4] Implemented provisioning check helper Implemented provisioning check helper which checks whether products use by the extension are fully provisioned. --- src/commands/ext-install.ts | 3 + src/extensions/provisioningHelper.ts | 135 ++++++++++ .../extensions/provisioningHelper.spec.ts | 245 ++++++++++++++++++ 3 files changed, 383 insertions(+) create mode 100644 src/extensions/provisioningHelper.ts create mode 100644 src/test/extensions/provisioningHelper.spec.ts 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/extensions/provisioningHelper.ts b/src/extensions/provisioningHelper.ts new file mode 100644 index 00000000000..075e34e6534 --- /dev/null +++ b/src/extensions/provisioningHelper.ts @@ -0,0 +1,135 @@ +import * as extensionsApi from "./extensionsApi"; +import * as api from "../api"; +import * as utils from "../utils"; +import * as marked from "marked"; +import { logPrefix } from "./extensionsHelper"; +import { FirebaseError } from "../error"; +import { logger, LogLevel } from "../logger"; + +const provisioningMsg = + "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 " + + "before completing installation by following the provided links.\n\n"; + +/** Product for which provisioning can be (or is) deferred */ +export enum DeferredProduct { + STORAGE, + AUTH, +} + +/** + * Checks which products used by the extension require provisioning. + * + * @param spec extension spec + * @returns array of products that require provisioning + */ +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 = provisioningMsg; + 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\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 hasDefaultBucketPromise = hasDefaultBucket(projectId); + const hasLinkedBucketPromise = hasLinkedBucket(projectId); + return (await hasDefaultBucketPromise) && (await hasLinkedBucketPromise); +} + +async function isAuthProvisioned(projectId: string): Promise { + const resp = await api.request("GET", `/v1/projects/${projectId}/products`, { + auth: true, + origin: api.firedataOrigin, + }); + return Promise.resolve( + !!resp.body?.activation?.map((a: any) => a.service).includes("FIREBASE_AUTH") + ); +} + +async function hasDefaultBucket(projectId: string): Promise { + try { + const resp = await api.request("GET", `/v1/apps/${projectId}`, { + auth: true, + origin: api.appengineOrigin, + }); + return await Promise.resolve(resp.body.defaultBucket !== "undefined"); + } catch (err) { + if (err.status === 404) { + return Promise.resolve(false); + } + throw err; + } +} + +async function hasLinkedBucket(projectId: string): Promise { + const resp = await api.request("GET", `/v1beta/projects/${projectId}/buckets`, { + auth: true, + origin: api.firebaseStorageOrigin, + }); + return await Promise.resolve(!!(resp.body?.buckets?.length > 0)); +} diff --git a/src/test/extensions/provisioningHelper.spec.ts b/src/test/extensions/provisioningHelper.spec.ts new file mode 100644 index 00000000000..aa4e5f7fd7b --- /dev/null +++ b/src/test/extensions/provisioningHelper.spec.ts @@ -0,0 +1,245 @@ +import * as nock from "nock"; +import * as api from "../../api"; +import * as provisioningHelper from "../../extensions/provisioningHelper"; +import * as extensionsApi from "../../extensions/extensionsApi"; +import { expect } from "chai"; +import { FirebaseError } from "../../error"; + +const TEST_INSTANCES_RESPONSE = {}; +const PROJECT_ID = "test-project"; + +const PROVISIONED_DEFAULT_BUCKEET = { + defaultBucket: "default-bucket", +}; +const FIREBASE_STORAGE_BUCKEETS = { + buckets: ["bucket1", "bucket2"], +}; +const FIREBASE_PRODUCT_ACTIVATIONS = { + activation: [ + { + service: "FIREBASE_AUTH", + }, + ], +}; + +describe.only("provisioningHelper", () => { + afterEach(() => { + nock.cleanAll(); + }); + + describe("getUsedProducts", () => { + it("returns empty array when nothing is used", () => { + expect( + provisioningHelper.getUsedProducts({ + 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) + ).to.be.empty; + }); + it("returns STORAGE when Storage API is used", () => { + expect( + provisioningHelper.getUsedProducts({ + apis: [ + { + apiName: "storage-component.googleapis.com", + }, + ] as extensionsApi.Api[], + resources: [] as extensionsApi.Resource[], + } as extensionsApi.ExtensionSpec) + ).to.be.deep.eq([provisioningHelper.DeferredProduct.STORAGE]); + }); + it("returns STORAGE when Storage Role is used", () => { + expect( + provisioningHelper.getUsedProducts({ + roles: [ + { + role: "storage.object.admin", + }, + ] as extensionsApi.Role[], + resources: [] as extensionsApi.Resource[], + } as extensionsApi.ExtensionSpec) + ).to.be.deep.eq([provisioningHelper.DeferredProduct.STORAGE]); + }); + it("returns STORAGE when Storage trigger is used", () => { + expect( + provisioningHelper.getUsedProducts({ + resources: [ + { + 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[], + } as extensionsApi.ExtensionSpec) + ).to.be.deep.eq([provisioningHelper.DeferredProduct.STORAGE]); + }); + it("returns AUTH when Authentication API is used", () => { + expect( + provisioningHelper.getUsedProducts({ + apis: [ + { + apiName: "identitytoolkit.googleapis.com", + }, + ] as extensionsApi.Api[], + resources: [] as extensionsApi.Resource[], + } as extensionsApi.ExtensionSpec) + ).to.be.deep.eq([provisioningHelper.DeferredProduct.AUTH]); + }); + it("returns AUTH when Authentication Role is used", () => { + expect( + provisioningHelper.getUsedProducts({ + roles: [ + { + role: "firebaseauth.user.admin", + }, + ] as extensionsApi.Role[], + resources: [] as extensionsApi.Resource[], + } as extensionsApi.ExtensionSpec) + ).to.be.deep.eq([provisioningHelper.DeferredProduct.AUTH]); + }); + it("returns AUTH when Auth trigger is used", () => { + expect( + provisioningHelper.getUsedProducts({ + resources: [ + { + 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[], + } as extensionsApi.ExtensionSpec) + ).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, FIREBASE_PRODUCT_ACTIVATIONS); + nock(api.appengineOrigin) + .get(`/v1/apps/${PROJECT_ID}`) + .reply(200, PROVISIONED_DEFAULT_BUCKEET); + nock(api.firebaseStorageOrigin) + .get(`/v1beta/projects/${PROJECT_ID}/buckets`) + .reply(200, { + buckets: ["bucket1", "bucket2"], + }); + + await expect( + provisioningHelper.checkProductsProvisioned(PROJECT_ID, { + apis: [ + { + apiName: "storage-component.googleapis.com", + }, + { + apiName: "identitytoolkit.googleapis.com", + }, + ] as extensionsApi.Api[], + resources: [] as extensionsApi.Resource[], + } as extensionsApi.ExtensionSpec) + ).to.be.fulfilled; + + expect(nock.isDone()).to.be.true; + }); + it("fails provisioning check storage when default bucket is not set", async () => { + nock(api.firedataOrigin) + .get(`/v1/projects/${PROJECT_ID}/products`) + .reply(200, FIREBASE_PRODUCT_ACTIVATIONS); + nock(api.appengineOrigin).get(`/v1/apps/${PROJECT_ID}`).reply(200, { + defaultBucket: "undefined", + }); + nock(api.firebaseStorageOrigin) + .get(`/v1beta/projects/${PROJECT_ID}/buckets`) + .reply(200, FIREBASE_STORAGE_BUCKEETS); + + await expect( + provisioningHelper.checkProductsProvisioned(PROJECT_ID, { + apis: [ + { + apiName: "storage-component.googleapis.com", + }, + { + apiName: "identitytoolkit.googleapis.com", + }, + ] as extensionsApi.Api[], + resources: [] as extensionsApi.Resource[], + } as extensionsApi.ExtensionSpec) + ).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, FIREBASE_PRODUCT_ACTIVATIONS); + nock(api.appengineOrigin) + .get(`/v1/apps/${PROJECT_ID}`) + .reply(200, PROVISIONED_DEFAULT_BUCKEET); + nock(api.firebaseStorageOrigin).get(`/v1beta/projects/${PROJECT_ID}/buckets`).reply(200, {}); + + await expect( + provisioningHelper.checkProductsProvisioned(PROJECT_ID, { + apis: [ + { + apiName: "storage-component.googleapis.com", + }, + { + apiName: "identitytoolkit.googleapis.com", + }, + ] as extensionsApi.Api[], + resources: [] as extensionsApi.Resource[], + } as extensionsApi.ExtensionSpec) + ).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.appengineOrigin) + .get(`/v1/apps/${PROJECT_ID}`) + .reply(200, PROVISIONED_DEFAULT_BUCKEET); + nock(api.firebaseStorageOrigin) + .get(`/v1beta/projects/${PROJECT_ID}/buckets`) + .reply(200, FIREBASE_STORAGE_BUCKEETS); + + await expect( + provisioningHelper.checkProductsProvisioned(PROJECT_ID, { + apis: [ + { + apiName: "storage-component.googleapis.com", + }, + { + apiName: "identitytoolkit.googleapis.com", + }, + ] as extensionsApi.Api[], + resources: [] as extensionsApi.Resource[], + } as extensionsApi.ExtensionSpec) + ).to.be.rejectedWith( + FirebaseError, + "Firebase Authentication: authenticate and manage users from" + ); + + expect(nock.isDone()).to.be.true; + }); + }); +}); From e2a9d68baa7b57ec63e520ef9ecd16cfad76b5f4 Mon Sep 17 00:00:00 2001 From: PavelJ Date: Wed, 16 Jun 2021 09:19:03 -0400 Subject: [PATCH 2/4] Simplified storage provisioning check Only check against Firebase Storage API and parse out default bucket. Also cleaned up some docs. --- src/extensions/provisioningHelper.ts | 47 +++----- .../extensions/provisioningHelper.spec.ts | 109 ++++++------------ 2 files changed, 54 insertions(+), 102 deletions(-) diff --git a/src/extensions/provisioningHelper.ts b/src/extensions/provisioningHelper.ts index 075e34e6534..5cd35ad451f 100644 --- a/src/extensions/provisioningHelper.ts +++ b/src/extensions/provisioningHelper.ts @@ -2,9 +2,7 @@ import * as extensionsApi from "./extensionsApi"; import * as api from "../api"; import * as utils from "../utils"; import * as marked from "marked"; -import { logPrefix } from "./extensionsHelper"; import { FirebaseError } from "../error"; -import { logger, LogLevel } from "../logger"; const provisioningMsg = "Some services used by this extension have not been set up on your " + @@ -18,10 +16,9 @@ export enum DeferredProduct { } /** - * Checks which products used by the extension require provisioning. + * Checks whether products used by the extension require provisioning. * * @param spec extension spec - * @returns array of products that require provisioning */ export async function checkProductsProvisioned( projectId: string, @@ -96,9 +93,22 @@ function getTriggerType(propertiesYaml: string | undefined) { } async function isStorageProvisioned(projectId: string): Promise { - const hasDefaultBucketPromise = hasDefaultBucket(projectId); - const hasLinkedBucketPromise = hasLinkedBucket(projectId); - return (await hasDefaultBucketPromise) && (await hasLinkedBucketPromise); + const resp = await api.request("GET", `/v1beta/projects/${projectId}/buckets`, { + auth: true, + origin: api.firebaseStorageOrigin, + }); + return await Promise.resolve( + !!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 { @@ -110,26 +120,3 @@ async function isAuthProvisioned(projectId: string): Promise { !!resp.body?.activation?.map((a: any) => a.service).includes("FIREBASE_AUTH") ); } - -async function hasDefaultBucket(projectId: string): Promise { - try { - const resp = await api.request("GET", `/v1/apps/${projectId}`, { - auth: true, - origin: api.appengineOrigin, - }); - return await Promise.resolve(resp.body.defaultBucket !== "undefined"); - } catch (err) { - if (err.status === 404) { - return Promise.resolve(false); - } - throw err; - } -} - -async function hasLinkedBucket(projectId: string): Promise { - const resp = await api.request("GET", `/v1beta/projects/${projectId}/buckets`, { - auth: true, - origin: api.firebaseStorageOrigin, - }); - return await Promise.resolve(!!(resp.body?.buckets?.length > 0)); -} diff --git a/src/test/extensions/provisioningHelper.spec.ts b/src/test/extensions/provisioningHelper.spec.ts index aa4e5f7fd7b..00e75e5818b 100644 --- a/src/test/extensions/provisioningHelper.spec.ts +++ b/src/test/extensions/provisioningHelper.spec.ts @@ -7,14 +7,19 @@ 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 PROVISIONED_DEFAULT_BUCKEET = { - defaultBucket: "default-bucket", -}; -const FIREBASE_STORAGE_BUCKEETS = { - buckets: ["bucket1", "bucket2"], -}; -const FIREBASE_PRODUCT_ACTIVATIONS = { +const FIREDATA_AUTH_ACTIVATED_RESPONSE = { activation: [ { service: "FIREBASE_AUTH", @@ -22,6 +27,14 @@ const FIREBASE_PRODUCT_ACTIVATIONS = { ], }; +const FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE = { + buckets: [ + { + name: `projects/12345/bucket/${PROJECT_ID}.appspot.com`, + }, + ], +}; + describe.only("provisioningHelper", () => { afterEach(() => { nock.cleanAll(); @@ -135,55 +148,33 @@ describe.only("provisioningHelper", () => { it("passes provisioning check when all is provisioned", async () => { nock(api.firedataOrigin) .get(`/v1/projects/${PROJECT_ID}/products`) - .reply(200, FIREBASE_PRODUCT_ACTIVATIONS); - nock(api.appengineOrigin) - .get(`/v1/apps/${PROJECT_ID}`) - .reply(200, PROVISIONED_DEFAULT_BUCKEET); + .reply(200, FIREDATA_AUTH_ACTIVATED_RESPONSE); nock(api.firebaseStorageOrigin) .get(`/v1beta/projects/${PROJECT_ID}/buckets`) - .reply(200, { - buckets: ["bucket1", "bucket2"], - }); + .reply(200, FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE); await expect( - provisioningHelper.checkProductsProvisioned(PROJECT_ID, { - apis: [ - { - apiName: "storage-component.googleapis.com", - }, - { - apiName: "identitytoolkit.googleapis.com", - }, - ] as extensionsApi.Api[], - resources: [] as extensionsApi.Resource[], - } as extensionsApi.ExtensionSpec) + 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 set", async () => { + it("fails provisioning check storage when default bucket is not linked", async () => { nock(api.firedataOrigin) .get(`/v1/projects/${PROJECT_ID}/products`) - .reply(200, FIREBASE_PRODUCT_ACTIVATIONS); - nock(api.appengineOrigin).get(`/v1/apps/${PROJECT_ID}`).reply(200, { - defaultBucket: "undefined", - }); + .reply(200, FIREDATA_AUTH_ACTIVATED_RESPONSE); nock(api.firebaseStorageOrigin) .get(`/v1beta/projects/${PROJECT_ID}/buckets`) - .reply(200, FIREBASE_STORAGE_BUCKEETS); - - await expect( - provisioningHelper.checkProductsProvisioned(PROJECT_ID, { - apis: [ - { - apiName: "storage-component.googleapis.com", - }, + .reply(200, { + buckets: [ { - apiName: "identitytoolkit.googleapis.com", + name: `projects/12345/bucket/some-other-bucket`, }, - ] as extensionsApi.Api[], - resources: [] as extensionsApi.Resource[], - } as extensionsApi.ExtensionSpec) + ], + }); + + 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; @@ -191,49 +182,23 @@ describe.only("provisioningHelper", () => { it("fails provisioning check storage when no firebase storage buckets", async () => { nock(api.firedataOrigin) .get(`/v1/projects/${PROJECT_ID}/products`) - .reply(200, FIREBASE_PRODUCT_ACTIVATIONS); - nock(api.appengineOrigin) - .get(`/v1/apps/${PROJECT_ID}`) - .reply(200, PROVISIONED_DEFAULT_BUCKEET); + .reply(200, FIREDATA_AUTH_ACTIVATED_RESPONSE); nock(api.firebaseStorageOrigin).get(`/v1beta/projects/${PROJECT_ID}/buckets`).reply(200, {}); await expect( - provisioningHelper.checkProductsProvisioned(PROJECT_ID, { - apis: [ - { - apiName: "storage-component.googleapis.com", - }, - { - apiName: "identitytoolkit.googleapis.com", - }, - ] as extensionsApi.Api[], - resources: [] as extensionsApi.Resource[], - } as extensionsApi.ExtensionSpec) + 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.appengineOrigin) - .get(`/v1/apps/${PROJECT_ID}`) - .reply(200, PROVISIONED_DEFAULT_BUCKEET); nock(api.firebaseStorageOrigin) .get(`/v1beta/projects/${PROJECT_ID}/buckets`) - .reply(200, FIREBASE_STORAGE_BUCKEETS); + .reply(200, FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE); await expect( - provisioningHelper.checkProductsProvisioned(PROJECT_ID, { - apis: [ - { - apiName: "storage-component.googleapis.com", - }, - { - apiName: "identitytoolkit.googleapis.com", - }, - ] as extensionsApi.Api[], - resources: [] as extensionsApi.Resource[], - } as extensionsApi.ExtensionSpec) + provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH) ).to.be.rejectedWith( FirebaseError, "Firebase Authentication: authenticate and manage users from" From dd9375730377449a506c78ccaa53a14d3a027965 Mon Sep 17 00:00:00 2001 From: PavelJ Date: Wed, 16 Jun 2021 12:05:30 -0400 Subject: [PATCH 3/4] Tweaked error message copy as per feedback --- src/extensions/provisioningHelper.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/extensions/provisioningHelper.ts b/src/extensions/provisioningHelper.ts index 5cd35ad451f..050701cde38 100644 --- a/src/extensions/provisioningHelper.ts +++ b/src/extensions/provisioningHelper.ts @@ -4,11 +4,6 @@ import * as utils from "../utils"; import * as marked from "marked"; import { FirebaseError } from "../error"; -const provisioningMsg = - "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 " + - "before completing installation by following the provided links.\n\n"; - /** Product for which provisioning can be (or is) deferred */ export enum DeferredProduct { STORAGE, @@ -43,16 +38,21 @@ export async function checkProductsProvisioned( } if (needProvisioning.length > 0) { - let errorMessage = provisioningMsg; + 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"; + " - 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\n"; + errorMessage += "\n"; } if (needProvisioning.includes(DeferredProduct.AUTH)) { errorMessage += - " - Firebase Authentication: authenticate and manage users from a variety of providers without server-side code.\n"; + " - 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 a8a69c8e31c88892748f02eedbd78d88843b0722 Mon Sep 17 00:00:00 2001 From: PavelJ Date: Fri, 18 Jun 2021 15:40:53 -0400 Subject: [PATCH 4/4] Addressed review feedback --- src/commands/ext-update.ts | 4 + src/extensions/provisioningHelper.ts | 28 ++- .../extensions/provisioningHelper.spec.ts | 161 +++++++++--------- 3 files changed, 94 insertions(+), 99 deletions(-) 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 index 050701cde38..38f2753b4a8 100644 --- a/src/extensions/provisioningHelper.ts +++ b/src/extensions/provisioningHelper.ts @@ -1,7 +1,7 @@ +import * as marked from "marked"; + import * as extensionsApi from "./extensionsApi"; import * as api from "../api"; -import * as utils from "../utils"; -import * as marked from "marked"; import { FirebaseError } from "../error"; /** Product for which provisioning can be (or is) deferred */ @@ -97,18 +97,14 @@ async function isStorageProvisioned(projectId: string): Promise { auth: true, origin: api.firebaseStorageOrigin, }); - return await Promise.resolve( - !!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] - ); - }) - ); + 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 { @@ -116,7 +112,5 @@ async function isAuthProvisioned(projectId: string): Promise { auth: true, origin: api.firedataOrigin, }); - return Promise.resolve( - !!resp.body?.activation?.map((a: any) => a.service).includes("FIREBASE_AUTH") - ); + 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 index 00e75e5818b..928d242575b 100644 --- a/src/test/extensions/provisioningHelper.spec.ts +++ b/src/test/extensions/provisioningHelper.spec.ts @@ -1,8 +1,9 @@ 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 { expect } from "chai"; import { FirebaseError } from "../../error"; const TEST_INSTANCES_RESPONSE = {}; @@ -35,105 +36,97 @@ const FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE = { ], }; -describe.only("provisioningHelper", () => { +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({ - 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) - ).to.be.empty; + expect(provisioningHelper.getUsedProducts(testSpec)).to.be.empty; }); + it("returns STORAGE when Storage API is used", () => { - expect( - provisioningHelper.getUsedProducts({ - apis: [ - { - apiName: "storage-component.googleapis.com", - }, - ] as extensionsApi.Api[], - resources: [] as extensionsApi.Resource[], - } as extensionsApi.ExtensionSpec) - ).to.be.deep.eq([provisioningHelper.DeferredProduct.STORAGE]); + 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", () => { - expect( - provisioningHelper.getUsedProducts({ - roles: [ - { - role: "storage.object.admin", - }, - ] as extensionsApi.Role[], - resources: [] as extensionsApi.Resource[], - } as extensionsApi.ExtensionSpec) - ).to.be.deep.eq([provisioningHelper.DeferredProduct.STORAGE]); + 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", () => { - expect( - provisioningHelper.getUsedProducts({ - resources: [ - { - 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[], - } as extensionsApi.ExtensionSpec) - ).to.be.deep.eq([provisioningHelper.DeferredProduct.STORAGE]); + 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", () => { - expect( - provisioningHelper.getUsedProducts({ - apis: [ - { - apiName: "identitytoolkit.googleapis.com", - }, - ] as extensionsApi.Api[], - resources: [] as extensionsApi.Resource[], - } as extensionsApi.ExtensionSpec) - ).to.be.deep.eq([provisioningHelper.DeferredProduct.AUTH]); + 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", () => { - expect( - provisioningHelper.getUsedProducts({ - roles: [ - { - role: "firebaseauth.user.admin", - }, - ] as extensionsApi.Role[], - resources: [] as extensionsApi.Resource[], - } as extensionsApi.ExtensionSpec) - ).to.be.deep.eq([provisioningHelper.DeferredProduct.AUTH]); + 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", () => { - expect( - provisioningHelper.getUsedProducts({ - resources: [ - { - 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[], - } as extensionsApi.ExtensionSpec) - ).to.be.deep.eq([provisioningHelper.DeferredProduct.AUTH]); + 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, + ]); }); }); @@ -145,6 +138,7 @@ describe.only("provisioningHelper", () => { } as extensionsApi.ExtensionSpec) ).to.be.fulfilled; }); + it("passes provisioning check when all is provisioned", async () => { nock(api.firedataOrigin) .get(`/v1/projects/${PROJECT_ID}/products`) @@ -159,6 +153,7 @@ describe.only("provisioningHelper", () => { 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`) @@ -179,6 +174,7 @@ describe.only("provisioningHelper", () => { 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`) @@ -191,6 +187,7 @@ describe.only("provisioningHelper", () => { 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)