diff --git a/src/commands/init.ts b/src/commands/init.ts index 1f8858d7cd2..0961c7a2ff2 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -117,6 +117,14 @@ if (isEnabled("apptesting")) { }); } +if (isEnabled("ailogic")) { + choices.push({ + value: "ailogic", + name: "AI Logic: Set up Firebase AI Logic with app provisioning", + checked: false, + }); +} + choices.push({ value: "aitools", name: "AI Tools: Configure AI coding assistants to work with your Firebase project", diff --git a/src/experiments.ts b/src/experiments.ts index 1084b5324bb..8c16408cb06 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -156,6 +156,13 @@ export const ALL_EXPERIMENTS = experiments({ shortDescription: "Adds experimental App Testing feature", public: true, }, + ailogic: { + shortDescription: "Enable Firebase AI Logic feature for existing apps", + fullDescription: + "Enables the AI Logic initialization feature that provisions AI Logic for existing Firebase apps.", + public: true, + default: false, + }, }); export type ExperimentName = keyof typeof ALL_EXPERIMENTS; diff --git a/src/init/features/ailogic/index.spec.ts b/src/init/features/ailogic/index.spec.ts new file mode 100644 index 00000000000..390ed5884d2 --- /dev/null +++ b/src/init/features/ailogic/index.spec.ts @@ -0,0 +1,216 @@ +import * as prompt from "../../../prompt"; +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as init from "./index"; +import * as utils from "./utils"; +import * as apps from "../../../management/apps"; +import * as provision from "../../../management/provisioning/provision"; +import { Setup } from "../.."; +import { AppPlatform } from "../../../management/apps"; + +describe("init ailogic", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("askQuestions", () => { + let listFirebaseAppsStub: sinon.SinonStub; + let selectStub: sinon.SinonStub; + + beforeEach(() => { + listFirebaseAppsStub = sandbox.stub(apps, "listFirebaseApps"); + selectStub = sandbox.stub(prompt, "select"); + }); + + it("should populate ailogic featureInfo with selected app ID", async () => { + const mockApps = [ + { + appId: "1:123456789:android:abcdef123456", + displayName: "Test Android App", + platform: AppPlatform.ANDROID, + }, + { + appId: "1:123456789:web:fedcba654321", + displayName: "Test Web App", + platform: AppPlatform.WEB, + }, + ]; + const mockSetup = { projectId: "test-project" } as Setup; + + listFirebaseAppsStub.resolves(mockApps); + selectStub.resolves(mockApps[0]); // Select first app + + await init.askQuestions(mockSetup); + + expect(mockSetup.featureInfo).to.have.property("ailogic"); + expect(mockSetup.featureInfo?.ailogic).to.deep.equal({ + appId: "1:123456789:android:abcdef123456", + }); + }); + + it("should throw error when no project ID is found", async () => { + const mockSetup = {} as Setup; // No projectId + + await expect(init.askQuestions(mockSetup)).to.be.rejectedWith( + "No project ID found. Please ensure you are in a Firebase project directory or specify a project.", + ); + + sinon.assert.notCalled(listFirebaseAppsStub); + sinon.assert.notCalled(selectStub); + }); + + it("should throw error when no apps are found", async () => { + const mockSetup = { projectId: "test-project" } as Setup; + listFirebaseAppsStub.resolves([]); // No apps + + await expect(init.askQuestions(mockSetup)).to.be.rejectedWith( + "No Firebase apps found in this project. Please create an app first using the Firebase Console or 'firebase apps:create'.", + ); + + sinon.assert.calledWith(listFirebaseAppsStub, "test-project", AppPlatform.ANY); + sinon.assert.notCalled(selectStub); + }); + }); + + describe("actuate", () => { + let setup: Setup; + let parseAppIdStub: sinon.SinonStub; + let provisionFirebaseAppStub: sinon.SinonStub; + let getConfigFileNameStub: sinon.SinonStub; + + beforeEach(() => { + setup = { + config: {}, + rcfile: { projects: {}, targets: {}, etags: {} }, + featureInfo: { + ailogic: { + appId: "1:123456789:android:abcdef123456", + }, + }, + projectId: "test-project", + instructions: [], + } as Setup; + + // Stub only the functions used in actuate (no validation stubs) + parseAppIdStub = sandbox.stub(utils, "parseAppId"); + provisionFirebaseAppStub = sandbox.stub(provision, "provisionFirebaseApp"); + getConfigFileNameStub = sandbox.stub(utils, "getConfigFileName"); + }); + + it("should return early if no ailogic feature info", async () => { + setup.featureInfo = {}; + + await init.actuate(setup); + + // No stubs should be called + sinon.assert.notCalled(parseAppIdStub); + sinon.assert.notCalled(provisionFirebaseAppStub); + }); + + it("should provision existing app successfully", async () => { + const mockAppInfo = { + projectNumber: "123456789", + appId: "1:123456789:android:abcdef123456", + platform: AppPlatform.ANDROID, + }; + const mockConfigContent = '{"config": "content"}'; + const base64Config = Buffer.from(mockConfigContent).toString("base64"); + + parseAppIdStub.returns(mockAppInfo); + provisionFirebaseAppStub.resolves({ configData: base64Config }); + getConfigFileNameStub.returns("google-services.json"); + + await init.actuate(setup); + + sinon.assert.calledWith(parseAppIdStub, "1:123456789:android:abcdef123456"); + sinon.assert.calledOnce(provisionFirebaseAppStub); + + expect(setup.instructions).to.include( + "Firebase AI Logic has been enabled for existing ANDROID app: 1:123456789:android:abcdef123456", + ); + expect(setup.instructions).to.include( + "Save the following content as google-services.json in your app's root directory:", + ); + expect(setup.instructions).to.include(mockConfigContent); + }); + + it("should throw error if no project ID found", async () => { + setup.projectId = undefined; + + await expect(init.actuate(setup)).to.be.rejectedWith( + "AI Logic setup failed: No project ID found. Please ensure you are in a Firebase project directory or specify a project.", + ); + + sinon.assert.calledOnce(parseAppIdStub); + sinon.assert.notCalled(provisionFirebaseAppStub); + }); + + it("should handle provisioning errors gracefully", async () => { + const mockAppInfo = { + projectNumber: "123456789", + appId: "1:123456789:android:abcdef123456", + platform: AppPlatform.ANDROID, + }; + + parseAppIdStub.returns(mockAppInfo); + provisionFirebaseAppStub.throws(new Error("Provisioning API failed")); + + await expect(init.actuate(setup)).to.be.rejectedWith( + "AI Logic setup failed: Provisioning API failed", + ); + }); + + it("should include config file content in instructions for iOS", async () => { + if (setup.featureInfo?.ailogic) { + setup.featureInfo.ailogic.appId = "1:123456789:ios:abcdef123456"; + } + const mockAppInfo = { + projectNumber: "123456789", + appId: "1:123456789:ios:abcdef123456", + platform: AppPlatform.IOS, + }; + const mockConfigContent = ''; + const base64Config = Buffer.from(mockConfigContent).toString("base64"); + + parseAppIdStub.returns(mockAppInfo); + provisionFirebaseAppStub.resolves({ configData: base64Config }); + getConfigFileNameStub.returns("GoogleService-Info.plist"); + + await init.actuate(setup); + + expect(setup.instructions).to.include( + "Firebase AI Logic has been enabled for existing IOS app: 1:123456789:ios:abcdef123456", + ); + expect(setup.instructions).to.include( + "Save the following content as GoogleService-Info.plist in your app's root directory:", + ); + expect(setup.instructions).to.include(mockConfigContent); + }); + + it("should include platform placement guidance in instructions", async () => { + const mockAppInfo = { + projectNumber: "123456789", + appId: "1:123456789:android:abcdef123456", + platform: AppPlatform.ANDROID, + }; + const mockConfigContent = '{"config": "content"}'; + const base64Config = Buffer.from(mockConfigContent).toString("base64"); + + parseAppIdStub.returns(mockAppInfo); + provisionFirebaseAppStub.resolves({ configData: base64Config }); + getConfigFileNameStub.returns("google-services.json"); + + await init.actuate(setup); + + expect(setup.instructions).to.include( + "Place this config file in the appropriate location for your platform.", + ); + }); + }); +}); diff --git a/src/init/features/ailogic/index.ts b/src/init/features/ailogic/index.ts new file mode 100644 index 00000000000..ba1e7a47085 --- /dev/null +++ b/src/init/features/ailogic/index.ts @@ -0,0 +1,146 @@ +import { select } from "../../../prompt"; +import { Setup } from "../.."; +import { FirebaseError } from "../../../error"; +import { AppInfo, getConfigFileName, parseAppId } from "./utils"; +import { listFirebaseApps, AppMetadata, AppPlatform } from "../../../management/apps"; +import { provisionFirebaseApp } from "../../../management/provisioning/provision"; +import { + ProvisionAppOptions, + ProvisionFirebaseAppOptions, +} from "../../../management/provisioning/types"; + +export interface AiLogicInfo { + appId: string; +} + +function checkForApps(apps: AppMetadata[]): void { + if (!apps.length) { + throw new FirebaseError( + "No Firebase apps found in this project. Please create an app first using the Firebase Console or 'firebase apps:create'.", + { exit: 1 }, + ); + } +} + +async function selectAppInteractively(apps: AppMetadata[]): Promise { + checkForApps(apps); + + const choices = apps.map((app) => { + let displayText = app.displayName || app.appId; + + if (!app.displayName) { + if (app.platform === AppPlatform.IOS && "bundleId" in app) { + displayText = app.bundleId as string; + } else if (app.platform === AppPlatform.ANDROID && "packageName" in app) { + displayText = app.packageName as string; + } + } + + return { + name: `${displayText} - ${app.appId} (${app.platform})`, + value: app, + }; + }); + + return await select({ + message: "Select the Firebase app to enable AI Logic for:", + choices, + }); +} + +/** + * Ask questions for AI Logic setup via CLI + */ +export async function askQuestions(setup: Setup): Promise { + if (!setup.projectId) { + throw new FirebaseError( + "No project ID found. Please ensure you are in a Firebase project directory or specify a project.", + { exit: 1 }, + ); + } + + const apps = await listFirebaseApps(setup.projectId, AppPlatform.ANY); + const selectedApp = await selectAppInteractively(apps); + + // Set up the feature info + if (!setup.featureInfo) { + setup.featureInfo = {}; + } + + setup.featureInfo.ailogic = { + appId: selectedApp.appId, + }; +} + +function getAppOptions(appInfo: AppInfo): ProvisionAppOptions { + switch (appInfo.platform) { + case AppPlatform.IOS: + return { + platform: AppPlatform.IOS, + appId: appInfo.appId, + }; + case AppPlatform.ANDROID: + return { + platform: AppPlatform.ANDROID, + appId: appInfo.appId, + }; + case AppPlatform.WEB: + return { + platform: AppPlatform.WEB, + appId: appInfo.appId, + }; + default: + throw new FirebaseError(`Unsupported platform ${appInfo.platform}`, { exit: 1 }); + } +} + +/** + * AI Logic provisioning: enables AI Logic via API (assumes app and project are already validated) + */ +export async function actuate(setup: Setup): Promise { + const ailogicInfo = setup.featureInfo?.ailogic as AiLogicInfo; + if (!ailogicInfo) { + return; + } + + try { + const appInfo = parseAppId(ailogicInfo.appId); + if (!setup.projectId) { + throw new FirebaseError( + "No project ID found. Please ensure you are in a Firebase project directory or specify a project.", + { exit: 1 }, + ); + } + + // Build provision options and call API directly + const provisionOptions: ProvisionFirebaseAppOptions = { + project: { + displayName: "Firebase Project", + parent: { type: "existing_project", projectId: setup.projectId }, + }, + app: getAppOptions(appInfo), + features: { + firebaseAiLogicInput: {}, + }, + }; + + const response = await provisionFirebaseApp(provisionOptions); + + const configFileName = getConfigFileName(appInfo.platform); + const configContent = Buffer.from(response.configData, "base64").toString("utf8"); + + setup.instructions.push( + `Firebase AI Logic has been enabled for existing ${appInfo.platform} app: ${ailogicInfo.appId}`, + `Save the following content as ${configFileName} in your app's root directory:`, + "", + configContent, + "", + "Place this config file in the appropriate location for your platform.", + ); + } catch (error) { + throw new FirebaseError( + `AI Logic setup failed: ${error instanceof Error ? error.message : String(error)}`, + { original: error instanceof Error ? error : new Error(String(error)), exit: 2 }, + ); + } +} diff --git a/src/init/features/ailogic/utils.spec.ts b/src/init/features/ailogic/utils.spec.ts new file mode 100644 index 00000000000..76524475227 --- /dev/null +++ b/src/init/features/ailogic/utils.spec.ts @@ -0,0 +1,198 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as utils from "./utils"; +import * as apps from "../../../management/apps"; +import { AppPlatform } from "../../../management/apps"; +import { FirebaseError } from "../../../error"; + +describe("ailogic utils", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("getConfigFileName", () => { + it("should return correct filename for iOS", () => { + expect(utils.getConfigFileName(AppPlatform.IOS)).to.equal("GoogleService-Info.plist"); + }); + + it("should return correct filename for Android", () => { + expect(utils.getConfigFileName(AppPlatform.ANDROID)).to.equal("google-services.json"); + }); + + it("should return correct filename for Web", () => { + expect(utils.getConfigFileName(AppPlatform.WEB)).to.equal("firebase-config.json"); + }); + + it("should throw error for unsupported platform", () => { + expect(() => utils.getConfigFileName("unsupported" as AppPlatform)).to.throw( + "Unsupported platform: unsupported", + ); + }); + }); + + describe("parseAppId", () => { + it("should parse valid app IDs and return AppInfo object", () => { + const validAppIds = [ + { + appId: "1:123456789:ios:123456789abcdef", + expected: { + projectNumber: "123456789", + appId: "1:123456789:ios:123456789abcdef", + platform: AppPlatform.IOS, + }, + }, + { + appId: "2:123456789:android:123456789abcdef", + expected: { + projectNumber: "123456789", + appId: "2:123456789:android:123456789abcdef", + platform: AppPlatform.ANDROID, + }, + }, + { + appId: "2:123456789:web:123456789abcdef", + expected: { + projectNumber: "123456789", + appId: "2:123456789:web:123456789abcdef", + platform: AppPlatform.WEB, + }, + }, + { + appId: "1:999999999:web:abcdef123456789", + expected: { + projectNumber: "999999999", + appId: "1:999999999:web:abcdef123456789", + platform: AppPlatform.WEB, + }, + }, + ]; + + validAppIds.forEach(({ appId, expected }) => { + const result = utils.parseAppId(appId); + expect(result).to.deep.equal(expected); + }); + }); + + it("should throw error for invalid app ID formats", () => { + const invalidAppIds = [ + "", + ":", + "1:", + "2:123456789", + "2:123456789:", + "2:123456789:test:", + "2:123456789:ios", + "2:123456789:web:", + "2:123456789:android:com_", + "invalid-id", + "1:abc:web:123456789abcdef", // non-numeric project number + "1:123456789:flutter:123456789abcdef", // unsupported platform + ]; + + invalidAppIds.forEach((appId) => { + expect(() => utils.parseAppId(appId)).to.throw(FirebaseError, /Invalid app ID format/); + }); + }); + }); + + describe("validateProjectNumberMatch", () => { + it("should not throw when project numbers match", () => { + const appInfo: utils.AppInfo = { + projectNumber: "123456789", + appId: "1:123456789:web:abcdef", + platform: AppPlatform.WEB, + }; + const projectInfo = { + projectNumber: "123456789", + projectId: "test-project", + name: "projects/test-project", + displayName: "Test Project", + }; + + expect(() => utils.validateProjectNumberMatch(appInfo, projectInfo)).to.not.throw(); + }); + + it("should throw when project numbers don't match", () => { + const appInfo: utils.AppInfo = { + projectNumber: "123456789", + appId: "1:123456789:web:abcdef", + platform: AppPlatform.WEB, + }; + const projectInfo = { + projectNumber: "987654321", + projectId: "test-project", + name: "projects/test-project", + displayName: "Test Project", + }; + + expect(() => utils.validateProjectNumberMatch(appInfo, projectInfo)).to.throw( + FirebaseError, + "App 1:123456789:web:abcdef belongs to project number 123456789 but current project has number 987654321.", + ); + }); + }); + + describe("validateAppExists", () => { + let getAppConfigStub: sinon.SinonStub; + + beforeEach(() => { + getAppConfigStub = sandbox.stub(apps, "getAppConfig"); + }); + + it("should not throw when app exists for web platform", async () => { + const appInfo: utils.AppInfo = { + projectNumber: "123456789", + appId: "1:123456789:web:abcdef", + platform: AppPlatform.WEB, + }; + getAppConfigStub.resolves({ mockConfig: true }); + + await expect(utils.validateAppExists(appInfo)).to.not.be.rejected; + sinon.assert.calledWith(getAppConfigStub, "1:123456789:web:abcdef", AppPlatform.WEB); + }); + + it("should not throw when app exists for ios platform", async () => { + const appInfo: utils.AppInfo = { + projectNumber: "123456789", + appId: "1:123456789:ios:abcdef", + platform: AppPlatform.IOS, + }; + getAppConfigStub.resolves({ mockConfig: true }); + + await expect(utils.validateAppExists(appInfo)).to.not.be.rejected; + sinon.assert.calledWith(getAppConfigStub, "1:123456789:ios:abcdef", AppPlatform.IOS); + }); + + it("should not throw when app exists for android platform", async () => { + const appInfo: utils.AppInfo = { + projectNumber: "123456789", + appId: "1:123456789:android:abcdef", + platform: AppPlatform.ANDROID, + }; + getAppConfigStub.resolves({ mockConfig: true }); + + await expect(utils.validateAppExists(appInfo)).to.not.be.rejected; + sinon.assert.calledWith(getAppConfigStub, "1:123456789:android:abcdef", AppPlatform.ANDROID); + }); + + it("should throw when app does not exist", async () => { + const appInfo: utils.AppInfo = { + projectNumber: "123456789", + appId: "1:123456789:web:nonexistent", + platform: AppPlatform.WEB, + }; + getAppConfigStub.throws(new Error("App not found")); + + await expect(utils.validateAppExists(appInfo)).to.be.rejectedWith( + FirebaseError, + "App 1:123456789:web:nonexistent does not exist or is not accessible.", + ); + }); + }); +}); diff --git a/src/init/features/ailogic/utils.ts b/src/init/features/ailogic/utils.ts new file mode 100644 index 00000000000..cf2fea5f1d0 --- /dev/null +++ b/src/init/features/ailogic/utils.ts @@ -0,0 +1,93 @@ +import { AppPlatform, getAppConfig } from "../../../management/apps"; +import { FirebaseError } from "../../../error"; +import { FirebaseProjectMetadata } from "../../../types/project"; + +/** + * Returns the Firebase configuration filename for a given platform + */ +export function getConfigFileName(platform: AppPlatform): string { + switch (platform) { + case AppPlatform.IOS: + return "GoogleService-Info.plist"; + case AppPlatform.ANDROID: + return "google-services.json"; + case AppPlatform.WEB: + return "firebase-config.json"; + default: + throw new FirebaseError(`Unsupported platform: ${platform as string}`, { exit: 2 }); + } +} + +export interface AppInfo { + projectNumber: string; + appId: string; + platform: AppPlatform; +} + +/** + * Parses Firebase app ID using official pattern - based on MobilesdkAppId.java + * Format: ::: + */ +export function parseAppId(appId: string): AppInfo { + const pattern = + /^(?\d+):(?\d+):(?ios|android|web):([0-9a-fA-F]+)$/; + const match = pattern.exec(appId); + + if (!match) { + throw new FirebaseError( + `Invalid app ID format: ${appId}. Expected format: 1:PROJECT_NUMBER:PLATFORM:IDENTIFIER`, + { exit: 1 }, + ); + } + + const platformString = match.groups?.platform || ""; + let platform: AppPlatform; + switch (platformString) { + case "ios": + platform = AppPlatform.IOS; + break; + case "android": + platform = AppPlatform.ANDROID; + break; + case "web": + platform = AppPlatform.WEB; + break; + default: + throw new FirebaseError(`Unsupported platform: ${platformString}`, { exit: 1 }); + } + + return { + projectNumber: match.groups?.projectNumber || "", + appId: appId, + platform, + }; +} + +/** + * Verify project number matches app ID's parsed project number + */ +export function validateProjectNumberMatch( + appInfo: AppInfo, + projectInfo: FirebaseProjectMetadata, +): void { + if (projectInfo.projectNumber !== appInfo.projectNumber) { + throw new FirebaseError( + `App ${appInfo.appId} belongs to project number ${appInfo.projectNumber} but current project has number ${projectInfo.projectNumber}.`, + { exit: 1 }, + ); + } +} + +/** + * Validate that app exists + */ +export async function validateAppExists(appInfo: AppInfo): Promise { + try { + await getAppConfig(appInfo.appId, appInfo.platform); + } catch (error) { + throw new FirebaseError(`App ${appInfo.appId} does not exist or is not accessible.`, { + exit: 1, + original: error instanceof Error ? error : new Error(String(error)), + }); + } +} diff --git a/src/init/features/index.ts b/src/init/features/index.ts index 0063eae1d8b..f5b5d34de50 100644 --- a/src/init/features/index.ts +++ b/src/init/features/index.ts @@ -40,3 +40,8 @@ export { actuate as apptestingAcutate, } from "./apptesting"; export { doSetup as aitools } from "./aitools"; +export { + askQuestions as aiLogicAskQuestions, + AiLogicInfo, + actuate as aiLogicActuate, +} from "./ailogic"; diff --git a/src/init/index.ts b/src/init/index.ts index dc7171186cf..f3ca925b9dc 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -38,6 +38,7 @@ export interface SetupInfo { dataconnectSdk?: features.DataconnectSdkInfo; storage?: features.StorageInfo; apptesting?: features.ApptestingInfo; + ailogic?: features.AiLogicInfo; } interface Feature { @@ -96,6 +97,11 @@ const featuresList: Feature[] = [ askQuestions: features.apptestingAskQuestions, actuate: features.apptestingAcutate, }, + { + name: "ailogic", + askQuestions: features.aiLogicAskQuestions, + actuate: features.aiLogicActuate, + }, { name: "aitools", displayName: "AI Tools", doSetup: features.aitools }, ]; diff --git a/src/management/provisioning/provision.spec.ts b/src/management/provisioning/provision.spec.ts new file mode 100644 index 00000000000..b97d7545f52 --- /dev/null +++ b/src/management/provisioning/provision.spec.ts @@ -0,0 +1,1234 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as nock from "nock"; +import { firebaseApiOrigin } from "../../api"; +import * as pollUtils from "../../operation-poller"; +import { + buildAppNamespace, + buildParentString, + buildProvisionRequest, + provisionFirebaseApp, +} from "./provision"; +import { + ProvisionAppOptions, + ProvisionFirebaseAppOptions, + ProvisionFirebaseAppResponse, +} from "./types"; +import { FirebaseError } from "../../error"; +import { AppPlatform } from "../apps"; + +// Test constants +const BUNDLE_ID = "com.example.testapp"; +const PACKAGE_NAME = "com.example.androidapp"; +const WEB_APP_ID = "web-app-123"; +const PROJECT_DISPLAY_NAME = "Test Project"; +const REQUEST_ID = "test-request-id-123"; +const LOCATION = "us-central1"; +const OPERATION_RESOURCE_NAME = "operations/provision.123456789"; +const APP_RESOURCE = "projects/test-project/apps/123456789"; +const CONFIG_DATA = "base64-encoded-config-data"; +const CONFIG_MIME_TYPE = "application/json"; + +describe("Provision module", () => { + let sandbox: sinon.SinonSandbox; + let pollOperationStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + pollOperationStub = sandbox.stub(pollUtils, "pollOperation"); + pollOperationStub.throws("Unexpected poll call"); + nock.disableNetConnect(); + }); + + afterEach(() => { + sandbox.restore(); + nock.enableNetConnect(); + nock.cleanAll(); + }); + + // Phase 1: buildAppNamespace helper function tests + describe("buildAppNamespace", () => { + it("should return appId when provided (takes precedence)", () => { + const appWithAppId: ProvisionAppOptions = { + platform: AppPlatform.IOS, + bundleId: BUNDLE_ID, + appId: "1:123456789:ios:abcdef123456", + }; + + const result = buildAppNamespace(appWithAppId); + + expect(result).to.equal("1:123456789:ios:abcdef123456"); + }); + + it("should return bundleId for iOS apps", () => { + const iosApp: ProvisionAppOptions = { + platform: AppPlatform.IOS, + bundleId: BUNDLE_ID, + }; + + const result = buildAppNamespace(iosApp); + + expect(result).to.equal(BUNDLE_ID); + }); + + it("should return packageName for Android apps", () => { + const androidApp: ProvisionAppOptions = { + platform: AppPlatform.ANDROID, + packageName: PACKAGE_NAME, + }; + + const result = buildAppNamespace(androidApp); + + expect(result).to.equal(PACKAGE_NAME); + }); + + it("should return webAppId for Web apps", () => { + const webApp: ProvisionAppOptions = { + platform: AppPlatform.WEB, + webAppId: WEB_APP_ID, + }; + + const result = buildAppNamespace(webApp); + + expect(result).to.equal(WEB_APP_ID); + }); + + it("should throw error for unsupported platform", () => { + const unsupportedApp = { + platform: "UNSUPPORTED" as any, + bundleId: BUNDLE_ID, + }; + + expect(() => buildAppNamespace(unsupportedApp)).to.throw("Unsupported platform"); + }); + + it("should throw error when iOS bundleId is empty", () => { + const iosApp: ProvisionAppOptions = { + platform: AppPlatform.IOS, + bundleId: "", + }; + + expect(() => buildAppNamespace(iosApp)).to.throw("App namespace cannot be empty"); + }); + + it("should throw error when Android packageName is empty", () => { + const androidApp: ProvisionAppOptions = { + platform: AppPlatform.ANDROID, + packageName: "", + }; + + expect(() => buildAppNamespace(androidApp)).to.throw("App namespace cannot be empty"); + }); + + it("should throw error when Web webAppId is empty", () => { + const webApp: ProvisionAppOptions = { + platform: AppPlatform.WEB, + webAppId: "", + }; + + expect(() => buildAppNamespace(webApp)).to.throw("App namespace cannot be empty"); + }); + + it("should throw error when iOS bundleId is missing", () => { + const iosApp: ProvisionAppOptions = { + platform: AppPlatform.IOS, + }; + + expect(() => buildAppNamespace(iosApp)).to.throw("App namespace cannot be empty"); + }); + + it("should throw error when Android packageName is missing", () => { + const androidApp: ProvisionAppOptions = { + platform: AppPlatform.ANDROID, + }; + + expect(() => buildAppNamespace(androidApp)).to.throw("App namespace cannot be empty"); + }); + + it("should throw error when Web webAppId is missing", () => { + const webApp: ProvisionAppOptions = { + platform: AppPlatform.WEB, + }; + + expect(() => buildAppNamespace(webApp)).to.throw("App namespace cannot be empty"); + }); + + it("should fall back to bundleId when appId is empty string", () => { + const appWithEmptyId: ProvisionAppOptions = { + platform: AppPlatform.IOS, + bundleId: BUNDLE_ID, + appId: "", + }; + + const result = buildAppNamespace(appWithEmptyId); + expect(result).to.equal(BUNDLE_ID); + }); + }); + + // Phase 2: buildParentString helper function tests + describe("buildParentString", () => { + it("should format existing project parent correctly", () => { + const parent = { + type: "existing_project" as const, + projectId: "my-project-123", + }; + + const result = buildParentString(parent); + + expect(result).to.equal("projects/my-project-123"); + }); + + it("should format organization parent correctly", () => { + const parent = { + type: "organization" as const, + organizationId: "123456789", + }; + + const result = buildParentString(parent); + + expect(result).to.equal("organizations/123456789"); + }); + + it("should format folder parent correctly", () => { + const parent = { + type: "folder" as const, + folderId: "987654321", + }; + + const result = buildParentString(parent); + + expect(result).to.equal("folders/987654321"); + }); + + it("should throw error for unsupported parent type", () => { + const unsupportedParent = { + type: "invalid" as any, + projectId: "test", + }; + + expect(() => buildParentString(unsupportedParent)).to.throw("Unsupported parent type"); + }); + }); + + // Phase 3: buildProvisionRequest helper function tests + describe("buildProvisionRequest", () => { + it("should build basic request with minimal options", () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.equal({ + appNamespace: WEB_APP_ID, + displayName: PROJECT_DISPLAY_NAME, + webInput: {}, + }); + }); + + it("should include parent when specified", () => { + const options: ProvisionFirebaseAppOptions = { + project: { + displayName: PROJECT_DISPLAY_NAME, + parent: { type: "existing_project", projectId: "my-project-123" }, + }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.include({ + appNamespace: WEB_APP_ID, + displayName: PROJECT_DISPLAY_NAME, + parent: "projects/my-project-123", + webInput: {}, + }); + }); + + it("should include location when specified", () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + features: { location: LOCATION }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.include({ + appNamespace: WEB_APP_ID, + displayName: PROJECT_DISPLAY_NAME, + location: LOCATION, + webInput: {}, + }); + }); + + it("should include requestId when specified", () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + requestId: REQUEST_ID, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.include({ + appNamespace: WEB_APP_ID, + displayName: PROJECT_DISPLAY_NAME, + requestId: REQUEST_ID, + webInput: {}, + }); + }); + + it("should build iOS-specific request correctly", () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.IOS, + bundleId: BUNDLE_ID, + appStoreId: "12345", + teamId: "TEAM123", + }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.equal({ + appNamespace: BUNDLE_ID, + displayName: PROJECT_DISPLAY_NAME, + appleInput: { + appStoreId: "12345", + teamId: "TEAM123", + }, + }); + }); + + it("should build Android-specific request correctly", () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.ANDROID, + packageName: PACKAGE_NAME, + sha1Hashes: ["sha1hash1", "sha1hash2"], + sha256Hashes: ["sha256hash1"], + }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.equal({ + appNamespace: PACKAGE_NAME, + displayName: PROJECT_DISPLAY_NAME, + androidInput: { + sha1Hashes: ["sha1hash1", "sha1hash2"], + sha256Hashes: ["sha256hash1"], + }, + }); + }); + + it("should build Web-specific request correctly", () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.equal({ + appNamespace: WEB_APP_ID, + displayName: PROJECT_DISPLAY_NAME, + webInput: {}, + }); + }); + + it("should include AI features when specified", () => { + const aiFeatures = { enableAiLogic: true, model: "gemini-pro" }; + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + features: { firebaseAiLogicInput: aiFeatures }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.include({ + appNamespace: WEB_APP_ID, + displayName: PROJECT_DISPLAY_NAME, + firebaseAiLogicInput: aiFeatures, + webInput: {}, + }); + }); + }); + + // Phase 4: Main Function Success Cases + describe("provisionFirebaseApp - Success Cases", () => { + const mockResponse: ProvisionFirebaseAppResponse = { + configMimeType: CONFIG_MIME_TYPE, + configData: CONFIG_DATA, + appResource: APP_RESOURCE, + }; + + it("should provision iOS app successfully", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.IOS, bundleId: BUNDLE_ID }, + }; + + // Mock API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + expect(pollOperationStub.calledOnce).to.be.true; + }); + + it("should provision Android app successfully", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.ANDROID, packageName: PACKAGE_NAME }, + }; + + // Mock API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + expect(pollOperationStub.calledOnce).to.be.true; + }); + + it("should provision Web app successfully", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + // Mock API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + expect(pollOperationStub.calledOnce).to.be.true; + }); + + it("should provision with existing project parent", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { + displayName: PROJECT_DISPLAY_NAME, + parent: { type: "existing_project", projectId: "parent-project-123" }, + }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + // Mock API call with parent verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.parent).to.equal("projects/parent-project-123"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with organization parent", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { + displayName: PROJECT_DISPLAY_NAME, + parent: { type: "organization", organizationId: "987654321" }, + }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + // Mock API call with parent verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.parent).to.equal("organizations/987654321"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with folder parent", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { + displayName: PROJECT_DISPLAY_NAME, + parent: { type: "folder", folderId: "123456789" }, + }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + // Mock API call with parent verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.parent).to.equal("folders/123456789"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with requestId for idempotency", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + requestId: REQUEST_ID, + }; + + // Mock API call with requestId verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.requestId).to.equal(REQUEST_ID); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with custom location", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + features: { location: LOCATION }, + }; + + // Mock API call with location verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.location).to.equal(LOCATION); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with AI features enabled", async () => { + const aiFeatures = { enableAiLogic: true, model: "gemini-pro" }; + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + features: { firebaseAiLogicInput: aiFeatures }, + }; + + // Mock API call with AI features verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.firebaseAiLogicInput).to.deep.equal(aiFeatures); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with all optional iOS fields", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.IOS, + bundleId: BUNDLE_ID, + appStoreId: "12345", + teamId: "TEAM123", + }, + }; + + // Mock API call with iOS fields verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.appleInput).to.deep.equal({ + appStoreId: "12345", + teamId: "TEAM123", + }); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with all optional Android fields", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.ANDROID, + packageName: PACKAGE_NAME, + sha1Hashes: ["sha1hash1", "sha1hash2"], + sha256Hashes: ["sha256hash1"], + }, + }; + + // Mock API call with Android fields verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.androidInput).to.deep.equal({ + sha1Hashes: ["sha1hash1", "sha1hash2"], + sha256Hashes: ["sha256hash1"], + }); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + }); + + // Phase 5: Request ID Behavior Tests + describe("provisionFirebaseApp - Request ID Behavior", () => { + const mockResponse: ProvisionFirebaseAppResponse = { + configMimeType: CONFIG_MIME_TYPE, + configData: CONFIG_DATA, + appResource: APP_RESOURCE, + }; + + const baseOptions: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + it("should work without requestId (undefined)", async () => { + const options = { ...baseOptions }; + // Explicitly ensure requestId is undefined + expect(options.requestId).to.be.undefined; + + // Mock API call and verify requestId is NOT included in request + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body).to.not.have.property("requestId"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should work with empty requestId", async () => { + const options: ProvisionFirebaseAppOptions = { + ...baseOptions, + requestId: "", + }; + + // Mock API call and verify empty requestId is NOT included in request + // (empty string is falsy, so it gets filtered out by the implementation) + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body).to.not.have.property("requestId"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should work with valid requestId", async () => { + const options: ProvisionFirebaseAppOptions = { + ...baseOptions, + requestId: REQUEST_ID, + }; + + // Mock API call and verify requestId is included in request + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.requestId).to.equal(REQUEST_ID); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should pass requestId to API when provided", async () => { + const customRequestId = "custom-request-12345"; + const options: ProvisionFirebaseAppOptions = { + ...baseOptions, + requestId: customRequestId, + }; + + let actualRequestBody: any; + + // Mock API call and capture request body + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, (uri, requestBody) => { + actualRequestBody = requestBody; + return { name: OPERATION_RESOURCE_NAME }; + }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + // Verify requestId was passed correctly + expect(actualRequestBody.requestId).to.equal(customRequestId); + expect(result).to.deep.equal(mockResponse); + }); + + it("should not include requestId in request when omitted", async () => { + const options = { ...baseOptions }; + // Ensure no requestId property exists + delete (options as any).requestId; + + let actualRequestBody: any; + + // Mock API call and capture request body + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, (uri, requestBody) => { + actualRequestBody = requestBody; + return { name: OPERATION_RESOURCE_NAME }; + }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + // Verify requestId property does not exist in request + expect(actualRequestBody).to.not.have.property("requestId"); + expect(result).to.deep.equal(mockResponse); + }); + }); + + // Phase 6: Error Handling Tests + describe("provisionFirebaseApp - Error Cases", () => { + const baseOptions: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + it("should reject if API call fails with 404", async () => { + // Mock API call failure + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(404, { error: { message: "Project not found" } }); + + // Ensure polling is never called for API failures + pollOperationStub.onFirstCall().throws("Polling should not be called on API failure"); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(pollOperationStub.notCalled).to.be.true; + } + }); + + it("should reject if API call fails with 403", async () => { + // Mock API call failure + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(403, { error: { message: "Permission denied" } }); + + // Ensure polling is never called for API failures + pollOperationStub.onFirstCall().throws("Polling should not be called on API failure"); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(pollOperationStub.notCalled).to.be.true; + } + }); + + it("should reject if API call fails with 500", async () => { + // Mock API call failure + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(500, { error: { message: "Internal server error" } }); + + // Ensure polling is never called for API failures + pollOperationStub.onFirstCall().throws("Polling should not be called on API failure"); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(pollOperationStub.notCalled).to.be.true; + } + }); + + it("should reject if polling operation fails", async () => { + // Mock successful API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling failure + const pollingError = new Error("Polling operation failed"); + pollOperationStub.onFirstCall().rejects(pollingError); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(error.message).to.include("Polling operation failed"); + expect(pollOperationStub.calledOnce).to.be.true; + } + }); + + it("should reject if polling operation times out", async () => { + // Mock successful API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling timeout + const timeoutError = new Error("Operation timed out"); + timeoutError.name = "TIMEOUT"; + pollOperationStub.onFirstCall().rejects(timeoutError); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(error.message).to.include("Operation timed out"); + expect(pollOperationStub.calledOnce).to.be.true; + } + }); + + it("should wrap unknown errors properly", async () => { + // Mock API call that throws unexpected error + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .replyWithError("Unexpected network error"); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(error.message).to.include("Failed to make request"); + expect(pollOperationStub.notCalled).to.be.true; + } + }); + + it("should preserve original error information", async () => { + const originalError = new Error("Original error message"); + originalError.name = "CustomError"; + (originalError as any).code = "CUSTOM_CODE"; + + // Mock API call failure + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling failure with custom error + pollOperationStub.onFirstCall().rejects(originalError); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(error.message).to.include("Original error message"); + + // Verify original error is preserved + const firebaseError = error as FirebaseError; + expect(firebaseError.original).to.equal(originalError); + expect(firebaseError.exit).to.equal(2); + } + }); + }); + + // Phase 7: Platform Validation Tests + describe("Platform-Specific Validation", () => { + const mockResponse: ProvisionFirebaseAppResponse = { + configMimeType: CONFIG_MIME_TYPE, + configData: CONFIG_DATA, + appResource: APP_RESOURCE, + }; + + it("should require bundleId for iOS apps", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.IOS, bundleId: BUNDLE_ID }, + }; + + // Mock API call to verify bundleId is included as appNamespace + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.appNamespace).to.equal(BUNDLE_ID); + expect(body.appleInput).to.exist; + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should require packageName for Android apps", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.ANDROID, packageName: PACKAGE_NAME }, + }; + + // Mock API call to verify packageName is included as appNamespace + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.appNamespace).to.equal(PACKAGE_NAME); + expect(body.androidInput).to.exist; + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should require webAppId for Web apps", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + // Mock API call to verify webAppId is included as appNamespace + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.appNamespace).to.equal(WEB_APP_ID); + expect(body.webInput).to.exist; + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should accept optional appStoreId for iOS", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.IOS, + bundleId: BUNDLE_ID, + appStoreId: "123456789", + }, + }; + + // Mock API call to verify appStoreId is included + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.appleInput.appStoreId).to.equal("123456789"); + expect(body.appleInput).to.not.have.property("teamId"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should accept optional teamId for iOS", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.IOS, + bundleId: BUNDLE_ID, + teamId: "TEAM123", + }, + }; + + // Mock API call to verify teamId is included + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.appleInput.teamId).to.equal("TEAM123"); + expect(body.appleInput).to.not.have.property("appStoreId"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should accept optional SHA hashes for Android", async () => { + const sha1Hashes = ["sha1hash1", "sha1hash2"]; + const sha256Hashes = ["sha256hash1"]; + + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.ANDROID, + packageName: PACKAGE_NAME, + sha1Hashes, + sha256Hashes, + }, + }; + + // Mock API call to verify SHA hashes are included + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.androidInput).to.deep.equal({ + sha1Hashes, + sha256Hashes, + }); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + }); + + // Phase 8: API Integration Tests + describe("API Integration", () => { + const mockResponse: ProvisionFirebaseAppResponse = { + configMimeType: CONFIG_MIME_TYPE, + configData: CONFIG_DATA, + appResource: APP_RESOURCE, + }; + + const baseOptions: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + it("should call correct API endpoint", async () => { + let actualApiEndpoint: string | undefined; + + // Capture the API endpoint that was called + nock(firebaseApiOrigin()) + .post((uri) => { + actualApiEndpoint = uri; + return uri === "/v1alpha/firebase:provisionFirebaseApp"; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(baseOptions); + + expect(actualApiEndpoint).to.equal("/v1alpha/firebase:provisionFirebaseApp"); + expect(result).to.deep.equal(mockResponse); + }); + + it("should use correct API version (v1alpha)", async () => { + // Mock the exact v1alpha endpoint + const scope = nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(baseOptions); + + // Verify the exact endpoint was called + expect(scope.isDone()).to.be.true; + expect(result).to.deep.equal(mockResponse); + }); + + it("should poll with correct API version (v1beta1)", async () => { + // Mock initial API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling - verify the polling call parameters + pollOperationStub.onFirstCall().callsFake(async (options) => { + expect(options.apiOrigin).to.equal(firebaseApiOrigin()); + expect(options.apiVersion).to.equal("v1beta1"); + expect(options.operationResourceName).to.equal(OPERATION_RESOURCE_NAME); + expect(options.pollerName).to.equal("Provision Firebase App Poller"); + return mockResponse; + }); + + const result = await provisionFirebaseApp(baseOptions); + + expect(result).to.deep.equal(mockResponse); + expect(pollOperationStub.calledOnce).to.be.true; + }); + + it("should pass correct request body structure", async () => { + let actualRequestBody: any; + + // Capture and verify request body structure + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, (uri, requestBody) => { + actualRequestBody = requestBody; + return { name: OPERATION_RESOURCE_NAME }; + }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(baseOptions); + + // Verify request body has correct structure + expect(actualRequestBody).to.have.property("appNamespace", WEB_APP_ID); + expect(actualRequestBody).to.have.property("displayName", PROJECT_DISPLAY_NAME); + expect(actualRequestBody).to.have.property("webInput"); + expect(actualRequestBody.webInput).to.deep.equal({}); + expect(result).to.deep.equal(mockResponse); + }); + + it("should handle LRO polling correctly", async () => { + // Mock initial API call returning operation + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling with detailed verification + pollOperationStub.onFirstCall().callsFake(async (options) => { + // Verify all polling parameters are correct + expect(options).to.deep.include({ + pollerName: "Provision Firebase App Poller", + apiOrigin: firebaseApiOrigin(), + apiVersion: "v1beta1", + operationResourceName: OPERATION_RESOURCE_NAME, + }); + + // Simulate successful polling result + return mockResponse; + }); + + const result = await provisionFirebaseApp(baseOptions); + + expect(result).to.deep.equal(mockResponse); + expect(pollOperationStub.calledOnce).to.be.true; + }); + + it("should return correct response type", async () => { + // Mock API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling with specific response structure + const expectedResponse: ProvisionFirebaseAppResponse = { + configMimeType: "application/json", + configData: "eyJ0ZXN0IjoiZGF0YSJ9", // base64 encoded test data + appResource: "projects/test-project-123/apps/web-app-456", + }; + + pollOperationStub.onFirstCall().resolves(expectedResponse); + + const result = await provisionFirebaseApp(baseOptions); + + // Verify the response structure and types + expect(result).to.have.property("configMimeType"); + expect(result).to.have.property("configData"); + expect(result).to.have.property("appResource"); + expect(typeof result.configMimeType).to.equal("string"); + expect(typeof result.configData).to.equal("string"); + expect(typeof result.appResource).to.equal("string"); + expect(result).to.deep.equal(expectedResponse); + }); + }); +}); diff --git a/src/management/provisioning/provision.ts b/src/management/provisioning/provision.ts new file mode 100644 index 00000000000..0e0df7f88ce --- /dev/null +++ b/src/management/provisioning/provision.ts @@ -0,0 +1,142 @@ +import { Client } from "../../apiv2"; +import { firebaseApiOrigin } from "../../api"; +import { FirebaseError } from "../../error"; +import { logger } from "../../logger"; +import { pollOperation } from "../../operation-poller"; +import { AppPlatform } from "../apps"; +import * as types from "./types"; + +const apiClient = new Client({ + urlPrefix: firebaseApiOrigin(), + apiVersion: "v1alpha", +}); + +/** + * Builds the appropriate app namespace string based on the platform type. + */ +export function buildAppNamespace(app: types.ProvisionAppOptions): string { + let namespace; + if (app.appId) { + return app.appId; + } + + switch (app.platform) { + case AppPlatform.IOS: + namespace = app.bundleId || ""; + break; + case AppPlatform.ANDROID: + namespace = app.packageName || ""; + break; + case AppPlatform.WEB: + namespace = app.webAppId || ""; + break; + default: + throw new FirebaseError("Unsupported platform", { exit: 2 }); + } + + if (!namespace) { + throw new FirebaseError("App namespace cannot be empty", { exit: 2 }); + } + + return namespace; +} + +/** + * Builds the parent resource string for Firebase project provisioning. + */ +export function buildParentString(parent: types.ProjectParentInput): string { + switch (parent.type) { + case "existing_project": + return `projects/${parent.projectId}`; + case "organization": + return `organizations/${parent.organizationId}`; + case "folder": + return `folders/${parent.folderId}`; + default: + throw new FirebaseError("Unsupported parent type", { exit: 2 }); + } +} + +/** + * Builds the complete provision request object from the provided options. + */ +export function buildProvisionRequest( + options: types.ProvisionFirebaseAppOptions, +): types.ProvisionRequest { + const platformInput = (() => { + switch (options.app.platform) { + case AppPlatform.IOS: + return { + appleInput: { + appStoreId: options.app.appStoreId, + teamId: options.app.teamId, + }, + }; + case AppPlatform.ANDROID: + return { + androidInput: { + sha1Hashes: options.app.sha1Hashes, + sha256Hashes: options.app.sha256Hashes, + }, + }; + case AppPlatform.WEB: + return { webInput: {} }; + } + })(); + + return { + appNamespace: buildAppNamespace(options.app), + displayName: options.project.displayName, + ...(options.project.parent && { parent: buildParentString(options.project.parent) }), + ...(options.features?.location && { location: options.features.location }), + ...(options.requestId && { requestId: options.requestId }), + ...(options.features?.firebaseAiLogicInput && { + firebaseAiLogicInput: options.features.firebaseAiLogicInput, + }), + ...platformInput, + }; +} + +/** + * Provisions a new Firebase App and associated resources using the provisionFirebaseApp API. + * @param options The provision options including project, app, and feature configurations + * @return Promise resolving to the provisioned Firebase app response containing config data and app resource name + */ +export async function provisionFirebaseApp( + options: types.ProvisionFirebaseAppOptions, +): Promise { + try { + const request = buildProvisionRequest(options); + + logger.debug("[provision] Starting Firebase app provisioning..."); + logger.debug(`[provision] Request: ${JSON.stringify(request, null, 2)}`); + + const response = await apiClient.request({ + method: "POST", + path: "/firebase:provisionFirebaseApp", + body: request, + }); + + logger.debug(`[provision] Operation started: ${response.body.name}`); + logger.debug("[provision] Polling for operation completion..."); + + const result = await pollOperation({ + pollerName: "Provision Firebase App Poller", + apiOrigin: firebaseApiOrigin(), + apiVersion: "v1beta1", + operationResourceName: response.body.name, + masterTimeout: 180000, // 3 minutes + backoff: 100, // Initial backoff of 100ms + maxBackoff: 5000, // Max backoff of 5s + }); + + logger.debug("[provision] Firebase app provisioning completed successfully"); + return result; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + throw new FirebaseError(`Failed to provision Firebase app: ${errorMessage}`, { + exit: 2, + original: err instanceof Error ? err : new Error(String(err)), + }); + } +} diff --git a/src/management/provisioning/types.ts b/src/management/provisioning/types.ts new file mode 100644 index 00000000000..f0774fffadf --- /dev/null +++ b/src/management/provisioning/types.ts @@ -0,0 +1,88 @@ +import { AppPlatform } from "../apps"; + +interface BaseProvisionAppOptions { + platform: AppPlatform; + appId?: string; +} + +interface IosAppOptions extends BaseProvisionAppOptions { + platform: AppPlatform.IOS; + bundleId?: string; + appStoreId?: string; + teamId?: string; +} + +interface AndroidAppOptions extends BaseProvisionAppOptions { + platform: AppPlatform.ANDROID; + packageName?: string; + sha1Hashes?: string[]; + sha256Hashes?: string[]; +} + +interface WebAppOptions extends BaseProvisionAppOptions { + platform: AppPlatform.WEB; + webAppId?: string; +} + +export type ProvisionAppOptions = IosAppOptions | AndroidAppOptions | WebAppOptions; + +export interface ProvisionFirebaseAppResponse { + configMimeType: string; + configData: string; + appResource: string; +} + +interface ExistingProjectInput { + type: "existing_project"; + projectId: string; +} + +interface OrganizationInput { + type: "organization"; + organizationId: string; +} + +interface FolderInput { + type: "folder"; + folderId: string; +} + +export type ProjectParentInput = ExistingProjectInput | OrganizationInput | FolderInput; + +export interface ProvisionProjectOptions { + displayName?: string; + parent?: ProjectParentInput; + // TODO(caot): Support specifying projectLabels and billing. + // projectLabels?: Record; + // cloudBillingAccountId?: string; +} + +export interface ProvisionFeatureOptions { + location?: string; + firebaseAiLogicInput?: Record; +} + +export interface ProvisionFirebaseAppOptions { + project: ProvisionProjectOptions; + app: ProvisionAppOptions; + features?: ProvisionFeatureOptions; + requestId?: string; +} + +export interface ProvisionRequest { + parent?: string; + displayName?: string; + appNamespace: string; + location?: string; + requestId?: string; + appleInput?: { + appStoreId?: string; + teamId?: string; + }; + androidInput?: { + sha1Hashes?: string[]; + sha256Hashes?: string[]; + }; + webInput?: {}; + firebaseAiLogicInput?: {}; +} diff --git a/src/mcp/tools/core/init.ts b/src/mcp/tools/core/init.ts index 7ce6ab23d21..6b7ffca2a78 100644 --- a/src/mcp/tools/core/init.ts +++ b/src/mcp/tools/core/init.ts @@ -5,12 +5,19 @@ import { DEFAULT_RULES } from "../../../init/features/database"; import { actuate, Setup, SetupInfo } from "../../../init/index"; import { freeTrialTermsLink } from "../../../dataconnect/freeTrial"; import { requireGeminiToS } from "../../errors"; +import { FirebaseError } from "../../../error"; +import { + parseAppId, + validateProjectNumberMatch, + validateAppExists, +} from "../../../init/features/ailogic/utils"; +import { getFirebaseProject } from "../../../management/projects"; export const init = tool( { name: "init", description: - "Initializes selected Firebase features in the workspace (Firestore, Data Connect, Realtime Database). All features are optional; provide only the products you wish to set up. " + + "Initializes selected Firebase features in the workspace (Firestore, Data Connect, Realtime Database, Firebase AI Logic). All features are optional; provide only the products you wish to set up. " + "You can initialize new features into an existing project directory, but re-initializing an existing feature may overwrite configuration. " + "To deploy the initialized features, run the `firebase deploy` command after `firebase_init` tool.", inputSchema: z.object({ @@ -121,6 +128,16 @@ export const init = tool( .describe( "Provide this object to initialize Firebase Storage in this project directory.", ), + ailogic: z + .object({ + app_id: z + .string() + .describe( + "Firebase app ID (format: 1:PROJECT_NUMBER:PLATFORM:APP_ID). Must be an existing app in your Firebase project.", + ), + }) + .optional() + .describe("Enable Firebase AI Logic feature for existing app"), }), }), annotations: { @@ -178,6 +195,26 @@ export const init = tool( apps: [], }; } + if (features.ailogic) { + // AI Logic requires a project + if (!projectId) { + throw new FirebaseError( + "AI Logic feature requires a Firebase project. Please specify a project ID.", + { exit: 1 }, + ); + } + + // Validate AI Logic app for MCP flow + const appInfo = parseAppId(features.ailogic.app_id); + const projectInfo = await getFirebaseProject(projectId); + validateProjectNumberMatch(appInfo, projectInfo); + await validateAppExists(appInfo); + + featuresList.push("ailogic"); + featureInfo.ailogic = { + appId: features.ailogic.app_id, + }; + } const setup: Setup = { config: config?.src, rcfile: rc?.data,