diff --git a/src/apphosting/backend.ts b/src/apphosting/backend.ts index 8a5aa579901..e4ffee16d01 100644 --- a/src/apphosting/backend.ts +++ b/src/apphosting/backend.ts @@ -1,7 +1,7 @@ import * as clc from "colorette"; import * as poller from "../operation-poller"; import * as apphosting from "../gcp/apphosting"; -import * as githubConnections from "./githubConnections"; +import * as githubConnections from "./developer-connect/githubConnections"; import { logBullet, logSuccess, logWarning, sleep } from "../utils"; import { apphostingOrigin, diff --git a/src/apphosting/developer-connect/githubConnections.spec.ts b/src/apphosting/developer-connect/githubConnections.spec.ts new file mode 100644 index 00000000000..c5862690f7d --- /dev/null +++ b/src/apphosting/developer-connect/githubConnections.spec.ts @@ -0,0 +1,416 @@ +import * as sinon from "sinon"; +import { expect } from "chai"; +import * as prompt from "../../prompt"; +import * as poller from "../../operation-poller"; +import * as devconnect from "../../gcp/devConnect"; +import * as repo from "./githubConnections"; +import * as utils from "../../utils"; +import * as srcUtils from "../../getProjectNumber"; +import * as rm from "../../gcp/resourceManager"; +// import { FirebaseError } from "../../error"; +import * as githubConnectionUtils from "./utils"; +import { completeConnection, mockRepo, mockRepos } from "./test-utils"; +// import { completeConnection, mockConn, mockRepo } from "./test-utils"; +import { projectId, location } from "./test-utils"; + +describe("githubConnections", () => { + describe("connect GitHub repo", () => { + const sandbox: sinon.SinonSandbox = sinon.createSandbox(); + + let promptOnceStub: sinon.SinonStub; + let getConnectionStub: sinon.SinonStub; + let serviceAccountHasRolesStub: sinon.SinonStub; + let getProjectNumberStub: sinon.SinonStub; + let openInBrowserPopupStub: sinon.SinonStub; + let listAppHostingConnectionsStub: sinon.SinonStub; + let createConnectionStub: sinon.SinonStub; + let listValidInstallationsStub: sinon.SinonStub; + let generateConnectionIdStub: sinon.SinonStub; + let fetchRepositoryCloneUrisStub: sinon.SinonStub; + let getOrCreateRepositoryStub: sinon.SinonStub; + let getConnectionForInstallationStub: sinon.SinonStub; + let getOrCreateConnectionStub: sinon.SinonStub; + + beforeEach(() => { + promptOnceStub = sandbox.stub(prompt, "promptOnce").throws("Unexpected promptOnce call"); + getConnectionStub = sandbox + .stub(devconnect, "getConnection") + .throws("Unexpected getConnection call"); + serviceAccountHasRolesStub = sandbox.stub(rm, "serviceAccountHasRoles").resolves(true); + sandbox.stub(utils, "openInBrowser").resolves(); + openInBrowserPopupStub = sandbox + .stub(utils, "openInBrowserPopup") + .throws("Unexpected openInBrowserPopup call"); + getProjectNumberStub = sandbox + .stub(srcUtils, "getProjectNumber") + .throws("Unexpected getProjectNumber call"); + listValidInstallationsStub = sandbox + .stub(githubConnectionUtils, "listValidInstallations") + .throws("Unexpected listValidInstallations call"); + listAppHostingConnectionsStub = sandbox + .stub(githubConnectionUtils, "listAppHostingConnections") + .throws("Unexpected listAllConnections call"); + createConnectionStub = sandbox + .stub(githubConnectionUtils, "createConnection") + .throws("Unexpected createConnection call"); + generateConnectionIdStub = sandbox + .stub(githubConnectionUtils, "generateConnectionId") + .throws("Unexpected generateConnectionId call"); + fetchRepositoryCloneUrisStub = sandbox + .stub(githubConnectionUtils, "fetchRepositoryCloneUris") + .throws("Unexpected fetchRepositoryCloneUris call"); + getOrCreateRepositoryStub = sandbox + .stub(githubConnectionUtils, "getOrCreateRepository") + .throws("Unexpected getOrCreateRepository call"); + getConnectionForInstallationStub = sandbox + .stub(githubConnectionUtils, "getConnectionForInstallation") + .throws("Unexpected getConnectionForInstallation call"); + getOrCreateConnectionStub = sandbox.stub(githubConnectionUtils, "getOrCreateConnection"); + }); + + const mockConnectionId = `apphosting-github-conn-124uifn23`; + + afterEach(() => { + sandbox.verifyAndRestore(); + }); + + // it("checks if secret manager admin role is granted for developer connect P4SA when creating an oauth connection", async () => { + // listAppHostingConnectionsStub.resolves([]); + // generateConnectionIdStub.onFirstCall().resolves(); + // createConnectionStub.resolves(completeConnection(mockConnectionId)); + // promptOnceStub.resolves("any key"); + // getProjectNumberStub.onFirstCall().resolves(projectId); + // openInBrowserPopupStub.resolves({ url: "", cleanup: sandbox.stub() }); + + // await repo.getOrCreateOauthConnection(projectId, location); + // expect(serviceAccountHasRolesStub).to.be.calledWith( + // projectId, + // `service-${projectId}@gcp-sa-devconnect.iam.gserviceaccount.com`, + // ["roles/secretmanager.admin"], + // true, + // ); + // }); + + it("links a github repository without an existing oauth connection", async () => { + const completedConnection = completeConnection(mockConnectionId); + listAppHostingConnectionsStub.onFirstCall().resolves([]); + generateConnectionIdStub.onFirstCall().resolves(); + createConnectionStub.onFirstCall().resolves(completedConnection); + getProjectNumberStub.onFirstCall().resolves(projectId); // Verifies the secret manager grant. + + // promptGitHubInstallation fetches the installations. + listValidInstallationsStub.resolves([ + { + id: "installationID", + name: "main-user", + type: "user", + }, + ]); + + promptOnceStub.onFirstCall().resolves("installationID"); // Uses existing Github Account installation. + listAppHostingConnectionsStub.onSecondCall().resolves([completedConnection]); // getConnectionForInstallation() returns sentinel connection. + + // linkGitHubRepository() + // -promptCloneUri() + fetchRepositoryCloneUrisStub.resolves([]); // fetchRepositoryCloneUris() returns repos + promptOnceStub.onSecondCall().resolves(mockRepos.repositories[0].remoteUri); // promptCloneUri() returns repo's clone uri. + getConnectionForInstallationStub.onFirstCall().resolves(completedConnection); + getOrCreateConnectionStub.onFirstCall().resolves(); + getOrCreateRepositoryStub.onFirstCall().resolves(); + + const r = await repo.linkGitHubRepository(projectId, location); + expect(getOrCreateRepositoryStub).to.be.called; + }); + + // it("links a github repository using a sentinel oauth connection", async () => { + // // linkGitHubRepository() + // // -getOrCreateFullyInstalledConnection() + // listConnectionsStub.onFirstCall().resolves([oauthConn]); + + // // promptGitHubInstallation fetches the installations. + // fetchGitHubInstallationsStub.resolves([ + // { + // id: "installationID", + // name: "main-user", + // type: "user", + // }, + // ]); + + // promptOnceStub.onFirstCall().resolves("installationID"); // Uses existing Github Account installation. + // listConnectionsStub.resolves([oauthConn]); // getConnectionForInstallation() returns sentinel connection. + // createConnectionStub.onFirstCall().resolves({ name: "op" }); // Poll on createsConnection(). + // pollOperationStub.onFirstCall().resolves(completeConn); // Polling returns the oauth stub connection created. + + // // linkGitHubRepository() + // // -promptCloneUri() + // listAllLinkableGitRepositoriesStub.resolves(repos.repositories); // fetchRepositoryCloneUris() returns repos + // promptOnceStub.onSecondCall().resolves(repos.repositories[0].remoteUri); // promptCloneUri() returns repo's clone uri. + + // // linkGitHubRepository() + // getConnectionStub.onSecondCall().resolves(completeConn); // getOrCreateConnection() returns a completed connection. + + // // -getOrCreateRepository() + // getRepositoryStub.rejects(new FirebaseError("error", { status: 404 })); // Repo not yet created. + // createRepositoryStub.resolves({ name: "op" }); // Poll on createGitRepositoryLink(). + // pollOperationStub.onSecondCall().resolves(repos.repositories[0]); // Polling returns the gitRepoLink. + + // const r = await repo.linkGitHubRepository(projectId, location); + // expect(getConnectionStub).to.be.calledWith(projectId, location, oauthConnectionId); + // expect(getConnectionStub).to.be.calledWith(projectId, location, connectionId); + // expect(createConnectionStub).to.be.calledOnce; + // expect(createConnectionStub).to.be.calledWithMatch( + // projectId, + // location, + // /apphosting-github-conn-.*/g, + // { + // appInstallationId: "installationID", + // authorizerCredential: oauthConn.githubConfig.authorizerCredential, + // }, + // ); + + // expect(r).to.be.deep.equal(repos.repositories[0]); // Returns the correct repo. + // }); + + // it("links a github repository with a new named connection", async () => { + // const namedConnectionId = `apphosting-named-${location}`; + + // const namedCompleteConn = { + // name: `projects/${projectId}/locations/${location}/connections/${namedConnectionId}`, + // disabled: false, + // createTime: "0", + // updateTime: "1", + // installationState: { + // stage: "COMPLETE", + // message: "complete", + // actionUri: "https://google.com", + // }, + // reconciling: false, + // }; + + // // linkGitHubRepository() + // // -getOrCreateFullyInstalledConnection() + // getConnectionStub.onFirstCall().rejects(new FirebaseError("error", { status: 404 })); // Named connection does not exist. + // getConnectionStub.onSecondCall().resolves(oauthConn); // Fetches oauth sentinel. + // // promptGitHubInstallation fetches the installations. + // fetchGitHubInstallationsStub.resolves([ + // { + // id: "installationID", + // name: "main-user", + // type: "user", + // }, + // ]); + // promptOnceStub.onFirstCall().resolves("installationID"); // Uses existing Github Account installation. + // listConnectionsStub.resolves([oauthConn]); // Installation has sentinel connection but not the named one. + + // // --createFullyInstalledConnection + // createConnectionStub.onFirstCall().resolves({ name: "op" }); // Poll on createsConnection(). + // pollOperationStub.onFirstCall().resolves(namedCompleteConn); // Polling returns the connection created. + + // // linkGitHubRepository() + // // -promptCloneUri() + // listAllLinkableGitRepositoriesStub.resolves(repos.repositories); // fetchRepositoryCloneUris() returns repos + // promptOnceStub.onSecondCall().resolves(repos.repositories[0].remoteUri); // promptCloneUri() returns repo's clone uri. + + // // linkGitHubRepository() + // getConnectionStub.onThirdCall().resolves(namedCompleteConn); // getOrCreateConnection() returns a completed connection. + + // // -getOrCreateRepository() + // getRepositoryStub.rejects(new FirebaseError("error", { status: 404 })); // Repo not yet created. + // createRepositoryStub.resolves({ name: "op" }); // Poll on createGitRepositoryLink(). + // pollOperationStub.onSecondCall().resolves(repos.repositories[0]); // Polling returns the gitRepoLink. + + // const r = await repo.linkGitHubRepository(projectId, location, namedConnectionId); + + // expect(r).to.be.deep.equal(repos.repositories[0]); + // expect(getConnectionStub).to.be.calledWith(projectId, location, oauthConnectionId); + // expect(getConnectionStub).to.be.calledWith(projectId, location, namedConnectionId); + // expect(createConnectionStub).to.be.calledWith(projectId, location, namedConnectionId, { + // appInstallationId: "installationID", + // authorizerCredential: oauthConn.githubConfig.authorizerCredential, + // }); + // }); + + // it("reuses an existing named connection to link github repo", async () => { + // const namedConnectionId = `apphosting-named-${location}`; + + // const namedCompleteConn = { + // name: `projects/${projectId}/locations/${location}/connections/${namedConnectionId}`, + // disabled: false, + // createTime: "0", + // updateTime: "1", + // installationState: { + // stage: "COMPLETE", + // message: "complete", + // actionUri: "https://google.com", + // }, + // reconciling: false, + // }; + + // // linkGitHubRepository() + // // -getOrCreateGithubConnectionWithSentinel() + // getConnectionStub.onFirstCall().resolves(namedCompleteConn); // Named connection already exists. + + // // -promptCloneUri() + // listAllLinkableGitRepositoriesStub.resolves(repos.repositories); // fetchRepositoryCloneUris() returns repos + // promptOnceStub.onFirstCall().resolves(repos.repositories[0].remoteUri); // Selects the repo's clone uri. + + // // linkGitHubRepository() + // getConnectionStub.onSecondCall().resolves(namedCompleteConn); // getOrCreateConnection() returns a completed connection. + + // // -getOrCreateRepository() + // getRepositoryStub.rejects(new FirebaseError("error", { status: 404 })); // Repo not yet created. + // createRepositoryStub.resolves({ name: "op" }); // Poll on createGitRepositoryLink(). + // pollOperationStub.resolves(repos.repositories[0]); // Polling returns the gitRepoLink. + + // const r = await repo.linkGitHubRepository(projectId, location, namedConnectionId); + + // expect(r).to.be.deep.equal(repos.repositories[0]); + // expect(getConnectionStub).to.be.calledWith(projectId, location, namedConnectionId); + // expect(getConnectionStub).to.not.be.calledWith(projectId, location, oauthConnectionId); + // expect(listConnectionsStub).to.not.be.called; + // expect(createConnectionStub).to.not.be.called; + // }); + + // it("re-uses existing repository it already exists", async () => { + // getConnectionStub.resolves(completeConn); + // listAllLinkableGitRepositoriesStub.resolves(repos.repositories); + // promptOnceStub.onFirstCall().resolves(repos.repositories[0].remoteUri); + // getRepositoryStub.resolves(repos.repositories[0]); + + // const r = await repo.getOrCreateRepository( + // projectId, + // location, + // connectionId, + // repos.repositories[0].remoteUri, + // ); + // expect(r).to.be.deep.equal(repos.repositories[0]); + // }); + // }); + + // describe("fetchRepositoryCloneUris", () => { + // const sandbox: sinon.SinonSandbox = sinon.createSandbox(); + // let listAllLinkableGitRepositoriesStub: sinon.SinonStub; + + // beforeEach(() => { + // listAllLinkableGitRepositoriesStub = sandbox + // .stub(devconnect, "listAllLinkableGitRepositories") + // .throws("Unexpected listAllLinkableGitRepositories call"); + // }); + + // afterEach(() => { + // sandbox.verifyAndRestore(); + // }); + + // it("should fetch all linkable repositories from multiple connections", async () => { + // const conn0 = mockConn("conn0"); + // const repo0 = mockRepo("repo-0"); + // const repo1 = mockRepo("repo-1"); + // listAllLinkableGitRepositoriesStub.onFirstCall().resolves([repo0, repo1]); + + // const repos = await repo.fetchRepositoryCloneUris(projectId, conn0); + + // expect(repos.length).to.equal(2); + // expect(repos).to.deep.equal([repo0.cloneUri, repo1.cloneUri]); + // }); + // }); + + // describe("ensureSecretManagerAdminGrant", () => { + // const sandbox: sinon.SinonSandbox = sinon.createSandbox(); + + // let promptOnceStub: sinon.SinonStub; + // let serviceAccountHasRolesStub: sinon.SinonStub; + // let addServiceAccountToRolesStub: sinon.SinonStub; + // let generateP4SAStub: sinon.SinonStub; + + // beforeEach(() => { + // promptOnceStub = sandbox.stub(prompt, "promptOnce").throws("Unexpected promptOnce call"); + // serviceAccountHasRolesStub = sandbox.stub(rm, "serviceAccountHasRoles"); + // sandbox.stub(srcUtils, "getProjectNumber").resolves(projectId); + // addServiceAccountToRolesStub = sandbox.stub(rm, "addServiceAccountToRoles"); + // generateP4SAStub = sandbox.stub(devconnect, "generateP4SA"); + // }); + + // afterEach(() => { + // sandbox.verifyAndRestore(); + // }); + + // it("does not prompt user if the developer connect P4SA already has secretmanager.admin permissions", async () => { + // serviceAccountHasRolesStub.resolves(true); + // await repo.ensureSecretManagerAdminGrant(projectId); + + // expect(serviceAccountHasRolesStub).calledWith( + // projectId, + // `service-${projectId}@gcp-sa-devconnect.iam.gserviceaccount.com`, + // ["roles/secretmanager.admin"], + // ); + // expect(promptOnceStub).to.not.be.called; + // }); + + // it("prompts user if the developer connect P4SA does not have secretmanager.admin permissions", async () => { + // serviceAccountHasRolesStub.resolves(false); + // promptOnceStub.resolves(true); + // addServiceAccountToRolesStub.resolves(); + + // await repo.ensureSecretManagerAdminGrant(projectId); + + // expect(serviceAccountHasRolesStub).calledWith( + // projectId, + // `service-${projectId}@gcp-sa-devconnect.iam.gserviceaccount.com`, + // ["roles/secretmanager.admin"], + // ); + + // expect(promptOnceStub).to.be.called; + // }); + + // it("tries to generate developer connect P4SA if adding role throws an error", async () => { + // serviceAccountHasRolesStub.resolves(false); + // promptOnceStub.resolves(true); + // generateP4SAStub.resolves(); + // addServiceAccountToRolesStub.onFirstCall().throws({ code: 400, status: 400 }); + // addServiceAccountToRolesStub.onSecondCall().resolves(); + + // await repo.ensureSecretManagerAdminGrant(projectId); + + // expect(serviceAccountHasRolesStub).calledWith( + // projectId, + // `service-${projectId}@gcp-sa-devconnect.iam.gserviceaccount.com`, + // ["roles/secretmanager.admin"], + // ).calledOnce; + // expect(generateP4SAStub).calledOnce; + // expect(promptOnceStub).to.be.called; + // }); + // }); + // describe("promptGitHubBranch", () => { + // const sandbox: sinon.SinonSandbox = sinon.createSandbox(); + + // let promptOnceStub: sinon.SinonStub; + // let listAllBranchesStub: sinon.SinonStub; + + // beforeEach(() => { + // promptOnceStub = sandbox.stub(prompt, "promptOnce").throws("Unexpected promptOnce call"); + // listAllBranchesStub = sandbox + // .stub(devconnect, "listAllBranches") + // .throws("Unexpected listAllBranches call"); + // }); + + // afterEach(() => { + // sandbox.verifyAndRestore(); + // }); + + // it("prompts user for branch", async () => { + // listAllBranchesStub.returns(new Set(["main", "test1"])); + + // promptOnceStub.onFirstCall().returns("main"); + // const testRepoLink = { + // name: "test", + // cloneUri: "/test", + // createTime: "", + // updateTime: "", + // deleteTime: "", + // reconciling: false, + // uid: "", + // }; + // await expect(repo.promptGitHubBranch(testRepoLink)).to.eventually.equal("main"); + // }); + }); +}); diff --git a/src/apphosting/githubConnections.ts b/src/apphosting/developer-connect/githubConnections.ts similarity index 52% rename from src/apphosting/githubConnections.ts rename to src/apphosting/developer-connect/githubConnections.ts index 17b521efc62..896ad1a7cd0 100644 --- a/src/apphosting/githubConnections.ts +++ b/src/apphosting/developer-connect/githubConnections.ts @@ -1,21 +1,28 @@ import * as clc from "colorette"; -import * as devConnect from "../gcp/devConnect"; -import * as rm from "../gcp/resourceManager"; -import * as poller from "../operation-poller"; -import * as utils from "../utils"; -import { FirebaseError } from "../error"; -import { promptOnce } from "../prompt"; -import { getProjectNumber } from "../getProjectNumber"; +import * as devConnect from "../../gcp/devConnect"; +import * as rm from "../../gcp/resourceManager"; +import * as utils from "../../utils"; +import { FirebaseError } from "../../error"; +import { promptOnce } from "../../prompt"; +import { getProjectNumber } from "../../getProjectNumber"; import { - apphostingGitHubAppInstallationURL, - developerConnectOrigin, - githubApiOrigin, -} from "../api"; + generateConnectionId, + listValidInstallations, + parseConnectionName, + getOrCreateConnection, + createConnection, + createFullyInstalledConnection, + getConnectionForInstallation, + listAppHostingConnections, + getOrCreateRepository, + fetchRepositoryCloneUris, +} from "./utils"; +import { apphostingGitHubAppInstallationURL, githubApiOrigin } from "../../api"; import * as fuzzy from "fuzzy"; import * as inquirer from "inquirer"; -import { Client } from "../apiv2"; +import { Client } from "../../apiv2"; const githubApiClient = new Client({ urlPrefix: githubApiOrigin(), auth: false }); @@ -32,87 +39,13 @@ interface GitHubCommit { message: string; } -interface ConnectionNameParts { - projectId: string; - location: string; - id: string; -} - -// Note: This does not match the sentinel oauth connection -const APPHOSTING_CONN_PATTERN = /.+\/apphosting-github-conn-.+$/; -const APPHOSTING_OAUTH_CONN_NAME = "firebase-app-hosting-github-oauth"; -const CONNECTION_NAME_REGEX = - /^projects\/(?[^\/]+)\/locations\/(?[^\/]+)\/connections\/(?[^\/]+)$/; - -/** - * Exported for unit testing. - * - * Example: /projects/my-project/locations/us-central1/connections/my-connection-id => { - * projectId: "my-project", - * location: "us-central1", - * id: "my-connection-id", - * } - */ -export function parseConnectionName(name: string): ConnectionNameParts | undefined { - const match = CONNECTION_NAME_REGEX.exec(name); - - if (!match || typeof match.groups === undefined) { - return; - } - const { projectId, location, id } = match.groups as unknown as ConnectionNameParts; - return { - projectId, - location, - id, - }; -} - -const devConnectPollerOptions: Omit = { - apiOrigin: developerConnectOrigin(), - apiVersion: "v1", - masterTimeout: 25 * 60 * 1_000, - maxBackoff: 10_000, -}; - -/** - * Exported for unit testing. - * - * Example usage: - * extractRepoSlugFromURI("https://github.com/user/repo.git") => "user/repo" - */ -export function extractRepoSlugFromUri(cloneUri: string): string | undefined { - const match = /github.com\/(.+).git/.exec(cloneUri); - if (!match) { - return undefined; - } - return match[1]; -} - -/** - * Exported for unit testing. - * - * Generates a repository ID. - * The relation is 1:* between Developer Connect Connection and GitHub Repositories. - */ -export function generateRepositoryId(remoteUri: string): string | undefined { - return extractRepoSlugFromUri(remoteUri)?.replaceAll("/", "-"); -} - -/** - * Generates connection id that matches specific id format recognized by all Firebase clients. - */ -function generateConnectionId(): string { - const randomHash = Math.random().toString(36).slice(6); - return `apphosting-github-conn-${randomHash}`; -} - const ADD_ACCOUNT_CHOICE = "@ADD_ACCOUNT"; const MANAGE_INSTALLATION_CHOICE = "@MANAGE_INSTALLATION"; /** * Prompts the user to create a GitHub connection. */ -export async function getOrCreateGithubConnectionWithSentinel( +export async function getOrCreateFullyInstalledConnection( projectId: string, location: string, createConnectionId?: string, @@ -158,6 +91,10 @@ export async function getOrCreateGithubConnectionWithSentinel( location, installationId, ); + + console.log( + `connection matching installation: ${JSON.stringify(connectionMatchingInstallation)}`, + ); if (connectionMatchingInstallation) { const { id: matchingConnectionId } = parseConnectionName(connectionMatchingInstallation.name)!; @@ -189,7 +126,7 @@ export async function linkGitHubRepository( location: string, createConnectionId?: string, ): Promise { - const connection = await getOrCreateGithubConnectionWithSentinel( + const connection = await getOrCreateFullyInstalledConnection( projectId, location, createConnectionId, @@ -215,47 +152,6 @@ export async function linkGitHubRepository( return repo; } -/** - * Creates a new DevConnect GitHub connection resource and ensures that it is fully configured on the GitHub - * side (ie associated with an account/org and some subset of repos within that scope). - * Copies over Oauth creds from the sentinel Oauth connection to save the user from having to - * reauthenticate with GitHub. - * @param projectId user's Firebase projectID - * @param location region where backend is being created - * @param connectionId id of connection to be created - * @param oauthConn user's oauth connection - * @param installationId represents an installation of the Firebase App Hosting GitHub app on a GitHub account / org - */ -async function createFullyInstalledConnection( - projectId: string, - location: string, - connectionId: string, - oauthConn: devConnect.Connection, - installationId: string, -): Promise { - let conn = await createConnection(projectId, location, connectionId, { - appInstallationId: installationId, - authorizerCredential: oauthConn.githubConfig?.authorizerCredential, - }); - - while (conn.installationState.stage !== "COMPLETE") { - utils.logBullet( - "Install the Firebase App Hosting GitHub app to enable access to GitHub repositories", - ); - const targetUri = conn.installationState.actionUri; - utils.logBullet(targetUri); - await utils.openInBrowser(targetUri); - await promptOnce({ - type: "input", - message: - "Press Enter once you have installed or configured the Firebase App Hosting GitHub app to access your GitHub repo.", - }); - conn = await devConnect.getConnection(projectId, location, connectionId); - } - - return conn; -} - async function manageInstallation(connection: devConnect.Connection): Promise { utils.logBullet( "Manage the Firebase App Hosting GitHub app to enable access to GitHub repositories", @@ -274,37 +170,6 @@ async function manageInstallation(connection: devConnect.Connection): Promise { - const connections = await listAppHostingConnections(projectId, location); - const connectionsMatchingInstallation = connections.filter( - (conn) => conn.githubConfig?.appInstallationId === installationId, - ); - if (connectionsMatchingInstallation.length === 0) { - return null; - } - - if (connectionsMatchingInstallation.length > 1) { - /** - * In the Firebase Console and previous versions of the CLI we create a - * connection and then choose an installation, which makes it possible for - * there to be more than one connection for the same installation. - * - * To handle this case gracefully we return the oldest matching connection. - */ - const sorted = devConnect.sortConnectionsByCreateTime(connectionsMatchingInstallation); - return sorted[0]; - } - - return connectionsMatchingInstallation[0]; -} - /** * Prompts the user to select which GitHub account to install the GitHub app. */ @@ -346,29 +211,6 @@ export async function promptGitHubInstallation( return installationName; } -/** - * A "valid" installation is either the user's account itself or any orgs they - * have access to that the GitHub app has been installed on. - */ -export async function listValidInstallations( - projectId: string, - location: string, - connection: devConnect.Connection, -): Promise { - const { id: connId } = parseConnectionName(connection.name)!; - let installations = await devConnect.fetchGitHubInstallations(projectId, location, connId); - - installations = installations.filter((installation) => { - return ( - (installation.type === "user" && - installation.name === connection.githubConfig?.authorizerCredential?.username) || - installation.type === "organization" - ); - }); - - return installations; -} - /** * Gets or creates the sentinel GitHub connection resource that contains our Firebase-wide GitHub Oauth token. * This Oauth token can be used to create other connections without reprompting the user to grant access. @@ -378,19 +220,15 @@ export async function getOrCreateOauthConnection( location: string, ): Promise { let conn: devConnect.Connection; - try { - conn = await devConnect.getConnection(projectId, location, APPHOSTING_OAUTH_CONN_NAME); - } catch (err: unknown) { - if ((err as any).status === 404) { - // Cloud build P4SA requires the secret manager admin role. - // This is required when creating an initial connection which is the Oauth connection in our case. - await ensureSecretManagerAdminGrant(projectId); - conn = await createConnection(projectId, location, APPHOSTING_OAUTH_CONN_NAME); - } else { - throw err; - } + const completedConnections = await listAppHostingConnections(projectId, location); + if (completedConnections.length > 0) { + return completedConnections[0]; } + const connectionId = generateConnectionId(); + await ensureSecretManagerAdminGrant(projectId); + conn = await createConnection(projectId, location, connectionId); + while (conn.installationState.stage === "PENDING_USER_OAUTH") { utils.logBullet("Please authorize the Firebase GitHub app by visiting this url:"); const { url, cleanup } = await utils.openInBrowserPopup( @@ -431,11 +269,11 @@ async function promptCloneUri( new inquirer.Separator(), ...fuzzy .filter(input, cloneUris, { - extract: (uri) => extractRepoSlugFromUri(uri) || "", + extract: (uri) => devConnect.extractRepoSlugFromUri(uri) || "", }) .map((result) => { return { - name: extractRepoSlugFromUri(result.original) || "", + name: devConnect.extractRepoSlugFromUri(result.original) || "", value: result.original, }; }), @@ -472,7 +310,7 @@ export async function promptGitHubBranch(repoLink: devConnect.GitRepositoryLink) }); utils.logWarning( - `The branch "${branch}" does not exist on "${extractRepoSlugFromUri(repoLink.cloneUri) ?? ""}". Please enter a valid branch for this repo.`, + `The branch "${branch}" does not exist on "${devConnect.extractRepoSlugFromUri(repoLink.cloneUri) ?? ""}". Please enter a valid branch for this repo.`, ); return branch; } @@ -541,123 +379,6 @@ export async function ensureSecretManagerAdminGrant(projectId: string): Promise< ); } -/** - * Creates a new Developer Connect Connection resource. Will typically need some initialization - * or configuration after being created. - */ -export async function createConnection( - projectId: string, - location: string, - connectionId: string, - githubConfig?: devConnect.GitHubConfig, -): Promise { - const op = await devConnect.createConnection(projectId, location, connectionId, githubConfig); - const conn = await poller.pollOperation({ - ...devConnectPollerOptions, - pollerName: `create-${location}-${connectionId}`, - operationResourceName: op.name, - }); - return conn; -} - -/** - * Gets or creates a new Developer Connect Connection resource. Will typically need some initialization - * Exported for unit testing. - */ -export async function getOrCreateConnection( - projectId: string, - location: string, - connectionId: string, - githubConfig?: devConnect.GitHubConfig, -): Promise { - let conn: devConnect.Connection; - try { - conn = await devConnect.getConnection(projectId, location, connectionId); - } catch (err: unknown) { - if ((err as any).status === 404) { - utils.logBullet("creating connection"); - conn = await createConnection(projectId, location, connectionId, githubConfig); - } else { - throw err; - } - } - return conn; -} - -/** - * Gets or creates a new Developer Connect GitRepositoryLink resource on a Developer Connect connection. - * Exported for unit testing. - */ -export async function getOrCreateRepository( - projectId: string, - location: string, - connectionId: string, - cloneUri: string, -): Promise { - const repositoryId = generateRepositoryId(cloneUri); - if (!repositoryId) { - throw new FirebaseError(`Failed to generate repositoryId for URI "${cloneUri}".`); - } - let repo: devConnect.GitRepositoryLink; - try { - repo = await devConnect.getGitRepositoryLink(projectId, location, connectionId, repositoryId); - } catch (err: unknown) { - if ((err as FirebaseError).status === 404) { - const op = await devConnect.createGitRepositoryLink( - projectId, - location, - connectionId, - repositoryId, - cloneUri, - ); - repo = await poller.pollOperation({ - ...devConnectPollerOptions, - pollerName: `create-${location}-${connectionId}-${repositoryId}`, - operationResourceName: op.name, - }); - } else { - throw err; - } - } - return repo; -} - -/** - * Lists all App Hosting Developer Connect Connections - * not including the OAuth Connection - * - * Exported for unit testing. - */ -export async function listAppHostingConnections( - projectId: string, - location: string, -): Promise { - const conns = await devConnect.listAllConnections(projectId, location); - - return conns.filter( - (conn) => - APPHOSTING_CONN_PATTERN.test(conn.name) && - conn.installationState.stage === "COMPLETE" && - !conn.disabled, - ); -} - -/** - * Fetch the git clone url using a Developer Connect GitRepositoryLink. - * - * Exported for unit testing. - */ -export async function fetchRepositoryCloneUris( - projectId: string, - connection: devConnect.Connection, -): Promise { - const { location, id } = parseConnectionName(connection.name)!; - const connectionRepos = await devConnect.listAllLinkableGitRepositories(projectId, location, id); - const cloneUris = connectionRepos.map((conn) => conn.cloneUri); - - return cloneUris; -} - /** * Gets the details of a GitHub branch from the GitHub REST API. */ diff --git a/src/apphosting/developer-connect/test-utils.ts b/src/apphosting/developer-connect/test-utils.ts new file mode 100644 index 00000000000..b94d14e4efd --- /dev/null +++ b/src/apphosting/developer-connect/test-utils.ts @@ -0,0 +1,90 @@ +import * as devconnect from "../../gcp/devConnect"; + +export const projectId = "projectId"; +export const location = "us-central1"; + +export function mockConn(id: string): devconnect.Connection { + return { + name: `projects/${projectId}/locations/${location}/connections/${id}`, + disabled: false, + createTime: "0", + updateTime: "1", + installationState: { + stage: "COMPLETE", + message: "complete", + actionUri: "https://google.com", + }, + reconciling: false, + }; +} + +export function mockRepo(name: string): devconnect.GitRepositoryLink { + return { + name: `${name}`, + cloneUri: `https://github.com/test/${name}.git`, + createTime: "", + updateTime: "", + deleteTime: "", + reconciling: false, + uid: "", + }; +} + +export function completedOperation(connectionId: string) { + return { + name: `projects/${projectId}/locations/${location}/connections/${connectionId}`, + done: true, + }; +} + +export function pendingConnection(connectionId: string) { + return { + name: `projects/${projectId}/locations/${location}/connections/${connectionId}`, + disabled: false, + createTime: "0", + updateTime: "1", + installationState: { + stage: "PENDING_USER_OAUTH", + message: "pending", + actionUri: "https://google.com", + }, + reconciling: false, + }; +} + +export function completeConnection(connectionId: string) { + return { + name: `projects/${projectId}/locations/${location}/connections/${connectionId}`, + disabled: false, + createTime: "0", + updateTime: "1", + installationState: { + stage: "COMPLETE", + message: "complete", + actionUri: "https://google.com", + }, + githubConfig: { + githubApp: "FIREBASE", + authorizerCredential: { + oauthTokenSecretVersion: "1", + username: "testUser", + }, + appInstallationId: "installationID", + installationUri: "http://uri", + }, + reconciling: false, + }; +} + +export const mockRepos = { + repositories: [ + { + name: "repo0", + remoteUri: "https://github.com/test/repo0.git", + }, + { + name: "repo1", + remoteUri: "https://github.com/test/repo1.git", + }, + ], +}; diff --git a/src/apphosting/developer-connect/utils.spec.ts b/src/apphosting/developer-connect/utils.spec.ts new file mode 100644 index 00000000000..28307721767 --- /dev/null +++ b/src/apphosting/developer-connect/utils.spec.ts @@ -0,0 +1,313 @@ +import * as devconnect from "../../gcp/devConnect"; +import * as sinon from "sinon"; +import * as githubConnectionsUtils from "./utils"; +import { expect } from "chai"; +import { + completeConnection, + completedOperation, + mockConn, + mockRepo, + mockRepos, + pendingConnection, +} from "./test-utils"; +import { projectId, location } from "./test-utils"; +import { FirebaseError } from "../../error"; +import * as poller from "../../operation-poller"; +import * as prompt from "../../prompt"; + +describe("utils", () => { + describe("generateRepositoryId", () => { + it("extracts repo from URI", () => { + const cloneUri = "https://github.com/user/repo.git"; + const repoSlug = githubConnectionsUtils.generateRepositoryId(cloneUri); + expect(repoSlug).to.equal("user-repo"); + }); + }); + + describe("github connections", () => { + const sandbox: sinon.SinonSandbox = sinon.createSandbox(); + + let promptOnceStub: sinon.SinonStub; + let getConnectionStub: sinon.SinonStub; + let createConnectionStub: sinon.SinonStub; + let pollOperationStub: sinon.SinonStub; + let listAllLinkableGitRepositoriesStub: sinon.SinonStub; + let getRepositoryStub: sinon.SinonStub; + let createRepositoryStub: sinon.SinonStub; + + const connectionId = `apphosting-${location}`; + + beforeEach(() => { + promptOnceStub = sandbox.stub(prompt, "promptOnce").throws("Unexpected promptOnce call"); + getConnectionStub = sandbox + .stub(devconnect, "getConnection") + .throws("Unexpected getConnection call"); + createConnectionStub = sandbox + .stub(devconnect, "createConnection") + .throws("Unexpected createConnection call"); + pollOperationStub = sandbox + .stub(poller, "pollOperation") + .throws("Unexpected pollOperation call"); + getRepositoryStub = sandbox + .stub(devconnect, "getGitRepositoryLink") + .throws("Unexpected getGitRepositoryLink call"); + createRepositoryStub = sandbox + .stub(devconnect, "createGitRepositoryLink") + .throws("Unexpected createGitRepositoryLink call"); + listAllLinkableGitRepositoriesStub = sandbox + .stub(devconnect, "listAllLinkableGitRepositories") + .throws("Unexpected listAllLinkableGitRepositories call"); + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + }); + + describe("parseConnectionName", () => { + it("should parse valid connection name", () => { + const connectionName = "projects/my-project/locations/us-central1/connections/my-conn"; + + const expected = { + projectId: "my-project", + location: "us-central1", + id: "my-conn", + }; + + expect(githubConnectionsUtils.parseConnectionName(connectionName)).to.deep.equal(expected); + }); + + it("should return undefined for invalid", () => { + expect( + githubConnectionsUtils.parseConnectionName( + "projects/my-project/locations/us-central1/connections/my-conn/repositories/repo", + ), + ).to.be.undefined; + expect(githubConnectionsUtils.parseConnectionName("foobar")).to.be.undefined; + }); + }); + + describe("listValidInstallations", () => { + const sandbox: sinon.SinonSandbox = sinon.createSandbox(); + let fetchGitHubInstallationsStub: sinon.SinonStub; + + beforeEach(() => { + fetchGitHubInstallationsStub = sandbox + .stub(devconnect, "fetchGitHubInstallations") + .throws("Unexpected fetchGitHubInstallations call"); + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + }); + + it("only lists organizations and authorizer github account", async () => { + const conn = mockConn("1"); + conn.githubConfig = { + authorizerCredential: { + oauthTokenSecretVersion: "blah", + username: "main-user", + }, + }; + + fetchGitHubInstallationsStub.resolves([ + { + id: "1", + name: "main-user", + type: "user", + }, + { + id: "2", + name: "org-1", + type: "organization", + }, + { + id: "3", + name: "org-3", + type: "organization", + }, + { + id: "4", + name: "some-other-user", + type: "user", + }, + { + id: "5", + name: "org-4", + type: "organization", + }, + ]); + + const installations = await githubConnectionsUtils.listValidInstallations( + projectId, + location, + conn, + ); + expect(installations).to.deep.equal([ + { + id: "1", + name: "main-user", + type: "user", + }, + { + id: "2", + name: "org-1", + type: "organization", + }, + { + id: "3", + name: "org-3", + type: "organization", + }, + { + id: "5", + name: "org-4", + type: "organization", + }, + ]); + }); + }); + + describe("getOrCreateConnection", () => { + it("creates a connection if it doesn't exist", async () => { + getConnectionStub.onFirstCall().rejects(new FirebaseError("error", { status: 404 })); + getConnectionStub.onSecondCall().resolves(completedOperation(connectionId)); + createConnectionStub.resolves(completedOperation(connectionId)); + pollOperationStub.resolves(pendingConnection(connectionId)); + promptOnceStub.onFirstCall().resolves("any key"); + + await githubConnectionsUtils.getOrCreateConnection(projectId, location, connectionId); + expect(createConnectionStub).to.be.calledWith(projectId, location, connectionId); + }); + }); + + describe("listAppHostingConnections", () => { + const sandbox: sinon.SinonSandbox = sinon.createSandbox(); + let listConnectionsStub: sinon.SinonStub; + + function extractId(name: string): string { + const parts = name.split("/"); + return parts.pop() ?? ""; + } + + beforeEach(() => { + listConnectionsStub = sandbox + .stub(devconnect, "listAllConnections") + .throws("Unexpected listAllConnections call"); + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + }); + + it("filters out non-apphosting connections", async () => { + listConnectionsStub.resolves([ + mockConn("apphosting-github-conn-baddcafe"), + mockConn("hooray-conn"), + mockConn("apphosting-github-conn-deadbeef"), + mockConn("apphosting-github-oauth"), + ]); + + const conns = await githubConnectionsUtils.listAppHostingConnections(projectId, location); + expect(conns).to.have.length(2); + expect(conns.map((c) => extractId(c.name))).to.include.members([ + "apphosting-github-conn-baddcafe", + "apphosting-github-conn-deadbeef", + ]); + }); + }); + + describe("getConnectionForInstallation", () => { + const sandbox: sinon.SinonSandbox = sinon.createSandbox(); + let listConnectionsStub: sinon.SinonStub; + + beforeEach(() => { + listConnectionsStub = sandbox + .stub(devconnect, "listAllConnections") + .throws("Unexpected listAllConnections call"); + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + }); + + it("finds the matching connection for a given installation", async () => { + const mockConn1 = mockConn("apphosting-github-conn-1"); + const mockConn2 = mockConn("apphosting-github-conn-2"); + const mockConn3 = mockConn("apphosting-github-conn-3"); + const mockConn4 = mockConn("random-conn"); + + const installationToMatch = "installation-1"; + + mockConn1.githubConfig = { + appInstallationId: installationToMatch, + }; + + mockConn2.githubConfig = { + appInstallationId: "installation-2", + }; + + mockConn3.githubConfig = { + appInstallationId: "installation-3", + }; + + listConnectionsStub.resolves([mockConn1, mockConn2, mockConn3, mockConn4]); + + const matchingConnection = await githubConnectionsUtils.getConnectionForInstallation( + projectId, + location, + installationToMatch, + ); + expect(matchingConnection).to.deep.equal(mockConn1); + }); + + it("returns null if there is no matching connection for a given installation", async () => { + const mockConn1 = mockConn("apphosting-github-conn-1"); + const mockConn2 = mockConn("apphosting-github-conn-2"); + + const installationToMatch = "random-installation"; + + mockConn1.githubConfig = { + appInstallationId: "installation-1", + }; + + mockConn2.githubConfig = { + appInstallationId: "installation-2", + }; + + listConnectionsStub.resolves([mockConn1, mockConn2]); + + const matchingConnection = await githubConnectionsUtils.getConnectionForInstallation( + projectId, + location, + installationToMatch, + ); + expect(matchingConnection).to.be.null; + }); + }); + + describe("getOrCreateRepository", () => { + it("creates repository if it doesn't exist", async () => { + getConnectionStub.resolves(completeConnection(connectionId)); + listAllLinkableGitRepositoriesStub.resolves(mockRepos.repositories); + promptOnceStub.onFirstCall().resolves(mockRepos.repositories[0].remoteUri); + getRepositoryStub.rejects(new FirebaseError("error", { status: 404 })); + createRepositoryStub.resolves({ name: "op" }); + pollOperationStub.resolves(mockRepos.repositories[0]); + + await githubConnectionsUtils.getOrCreateRepository( + projectId, + location, + connectionId, + mockRepos.repositories[0].remoteUri, + ); + expect(createRepositoryStub).to.be.calledWith( + projectId, + location, + connectionId, + "test-repo0", + mockRepos.repositories[0].remoteUri, + ); + }); + }); + }); +}); diff --git a/src/apphosting/developer-connect/utils.ts b/src/apphosting/developer-connect/utils.ts new file mode 100644 index 00000000000..5ccc1c3d9c7 --- /dev/null +++ b/src/apphosting/developer-connect/utils.ts @@ -0,0 +1,278 @@ +import { developerConnectOrigin } from "../../api"; +import * as devConnect from "../../gcp/devConnect"; +import * as poller from "../../operation-poller"; +import * as utils from "../../utils"; +import { promptOnce } from "../../prompt"; +import { FirebaseError } from "../../error"; + +export const devConnectPollerOptions: Omit = + { + apiOrigin: developerConnectOrigin(), + apiVersion: "v1", + masterTimeout: 25 * 60 * 1_000, + maxBackoff: 10_000, + }; + +const CONNECTION_NAME_REGEX = + /^projects\/(?[^\/]+)\/locations\/(?[^\/]+)\/connections\/(?[^\/]+)$/; +const APPHOSTING_CONN_PATTERN = /.+\/apphosting-github-conn-.+$/; + +interface ConnectionNameParts { + projectId: string; + location: string; + id: string; +} + +/** + * + * Example: /projects/my-project/locations/us-central1/connections/my-connection-id => { + * projectId: "my-project", + * location: "us-central1", + * id: "my-connection-id", + * } + */ +export function parseConnectionName(name: string): ConnectionNameParts | undefined { + const match = CONNECTION_NAME_REGEX.exec(name); + + if (!match || typeof match.groups === undefined) { + return; + } + const { projectId, location, id } = match.groups as unknown as ConnectionNameParts; + return { + projectId, + location, + id, + }; +} + +/** + * Generates connection id that matches specific id format recognized by all Firebase clients. + */ +export function generateConnectionId(): string { + const randomHash = Math.random().toString(36).slice(6); + return `apphosting-github-conn-${randomHash}`; +} + +/** + * Exported for unit testing. + * + * Generates a repository ID. + * The relation is 1:* between Developer Connect Connection and GitHub Repositories. + */ +export function generateRepositoryId(remoteUri: string): string | undefined { + return devConnect.extractRepoSlugFromUri(remoteUri)?.replaceAll("/", "-"); +} + +/** + * A "valid" installation is either the user's account itself or any orgs they + * have access to that the GitHub app has been installed on. + */ +export async function listValidInstallations( + projectId: string, + location: string, + connection: devConnect.Connection, +): Promise { + const { id: connId } = parseConnectionName(connection.name)!; + let installations = await devConnect.fetchGitHubInstallations(projectId, location, connId); + + installations = installations.filter((installation) => { + return ( + (installation.type === "user" && + installation.name === connection.githubConfig?.authorizerCredential?.username) || + installation.type === "organization" + ); + }); + + return installations; +} + +/** + * Creates a new Developer Connect Connection resource. Will typically need some initialization + * or configuration after being created. + */ +export async function createConnection( + projectId: string, + location: string, + connectionId: string, + githubConfig?: devConnect.GitHubConfig, +): Promise { + const op = await devConnect.createConnection(projectId, location, connectionId, githubConfig); + const conn = await poller.pollOperation({ + ...devConnectPollerOptions, + pollerName: `create-${location}-${connectionId}`, + operationResourceName: op.name, + }); + + return conn; +} + +/** + * Gets or creates a new Developer Connect Connection resource. Will typically need some initialization + * Exported for unit testing. + */ +export async function getOrCreateConnection( + projectId: string, + location: string, + connectionId: string, + githubConfig?: devConnect.GitHubConfig, +): Promise { + let conn: devConnect.Connection; + try { + conn = await devConnect.getConnection(projectId, location, connectionId); + } catch (err: unknown) { + if ((err as any).status === 404) { + utils.logBullet("creating connection"); + conn = await createConnection(projectId, location, connectionId, githubConfig); + } else { + throw err; + } + } + return conn; +} + +/** + * Lists all App Hosting Developer Connect Connections + * not including the OAuth Connection + * + * Exported for unit testing. + */ +export async function listAppHostingConnections( + projectId: string, + location: string, +): Promise { + const conns = await devConnect.listAllConnections(projectId, location); + + return conns.filter( + (conn) => + APPHOSTING_CONN_PATTERN.test(conn.name) && + conn.installationState.stage === "COMPLETE" && + !conn.disabled, + ); +} + +/** + * Gets the oldest matching Dev Connect connection resource for a GitHub app installation. + */ +export async function getConnectionForInstallation( + projectId: string, + location: string, + installationId: string, +): Promise { + const connections = await listAppHostingConnections(projectId, location); + const connectionsMatchingInstallation = connections.filter( + (conn) => conn.githubConfig?.appInstallationId === installationId, + ); + + if (connectionsMatchingInstallation.length === 0) { + return null; + } + + if (connectionsMatchingInstallation.length > 1) { + /** + * In the Firebase Console and previous versions of the CLI we create a + * connection and then choose an installation, which makes it possible for + * there to be more than one connection for the same installation. + * + * To handle this case gracefully we return the oldest matching connection. + */ + const sorted = devConnect.sortConnectionsByCreateTime(connectionsMatchingInstallation); + return sorted[0]; + } + + return connectionsMatchingInstallation[0]; +} + +/** + * Creates a new DevConnect GitHub connection resource and ensures that it is fully configured on the GitHub + * side (ie associated with an account/org and some subset of repos within that scope). + * Copies over Oauth creds from the sentinel Oauth connection to save the user from having to + * reauthenticate with GitHub. + * @param projectId user's Firebase projectID + * @param location region where backend is being created + * @param connectionId id of connection to be created + * @param oauthConn user's oauth connection + * @param installationId represents an installation of the Firebase App Hosting GitHub app on a GitHub account / org + */ +export async function createFullyInstalledConnection( + projectId: string, + location: string, + connectionId: string, + oauthConn: devConnect.Connection, + installationId: string, +): Promise { + let conn = await createConnection(projectId, location, connectionId, { + appInstallationId: installationId, + authorizerCredential: oauthConn.githubConfig?.authorizerCredential, + }); + + while (conn.installationState.stage !== "COMPLETE") { + utils.logBullet( + "Install the Firebase App Hosting GitHub app to enable access to GitHub repositories", + ); + const targetUri = conn.installationState.actionUri; + utils.logBullet(targetUri); + await utils.openInBrowser(targetUri); + await promptOnce({ + type: "input", + message: + "Press Enter once you have installed or configured the Firebase App Hosting GitHub app to access your GitHub repo.", + }); + conn = await devConnect.getConnection(projectId, location, connectionId); + } + + return conn; +} + +/** + * Gets or creates a new Developer Connect GitRepositoryLink resource on a Developer Connect connection. + * Exported for unit testing. + */ +export async function getOrCreateRepository( + projectId: string, + location: string, + connectionId: string, + cloneUri: string, +): Promise { + const repositoryId = generateRepositoryId(cloneUri); + if (!repositoryId) { + throw new FirebaseError(`Failed to generate repositoryId for URI "${cloneUri}".`); + } + let repo: devConnect.GitRepositoryLink; + try { + repo = await devConnect.getGitRepositoryLink(projectId, location, connectionId, repositoryId); + } catch (err: unknown) { + if ((err as FirebaseError).status === 404) { + const op = await devConnect.createGitRepositoryLink( + projectId, + location, + connectionId, + repositoryId, + cloneUri, + ); + repo = await poller.pollOperation({ + ...devConnectPollerOptions, + pollerName: `create-${location}-${connectionId}-${repositoryId}`, + operationResourceName: op.name, + }); + } else { + throw err; + } + } + return repo; +} + +/** + * Fetch the git clone url using a Developer Connect GitRepositoryLink. + * + * Exported for unit testing. + */ +export async function fetchRepositoryCloneUris( + projectId: string, + connection: devConnect.Connection, +): Promise { + const { location, id } = parseConnectionName(connection.name)!; + const connectionRepos = await devConnect.listAllLinkableGitRepositories(projectId, location, id); + const cloneUris = connectionRepos.map((conn) => conn.cloneUri); + + return cloneUris; +} diff --git a/src/apphosting/githubConnections.spec.ts b/src/apphosting/githubConnections.spec.ts deleted file mode 100644 index ff0988dd695..00000000000 --- a/src/apphosting/githubConnections.spec.ts +++ /dev/null @@ -1,788 +0,0 @@ -import * as sinon from "sinon"; -import { expect } from "chai"; -import * as prompt from "../prompt"; -import * as poller from "../operation-poller"; -import * as devconnect from "../gcp/devConnect"; -import * as repo from "./githubConnections"; -import * as utils from "../utils"; -import * as srcUtils from "../getProjectNumber"; -import * as rm from "../gcp/resourceManager"; -import { FirebaseError } from "../error"; - -const projectId = "projectId"; -const location = "us-central1"; - -function mockConn(id: string): devconnect.Connection { - return { - name: `projects/${projectId}/locations/${location}/connections/${id}`, - disabled: false, - createTime: "0", - updateTime: "1", - installationState: { - stage: "COMPLETE", - message: "complete", - actionUri: "https://google.com", - }, - reconciling: false, - }; -} - -function mockRepo(name: string): devconnect.GitRepositoryLink { - return { - name: `${name}`, - cloneUri: `https://github.com/test/${name}.git`, - createTime: "", - updateTime: "", - deleteTime: "", - reconciling: false, - uid: "", - }; -} - -describe("githubConnections", () => { - describe("parseConnectionName", () => { - it("should parse valid connection name", () => { - const connectionName = "projects/my-project/locations/us-central1/connections/my-conn"; - - const expected = { - projectId: "my-project", - location: "us-central1", - id: "my-conn", - }; - - expect(repo.parseConnectionName(connectionName)).to.deep.equal(expected); - }); - - it("should return undefined for invalid", () => { - expect( - repo.parseConnectionName( - "projects/my-project/locations/us-central1/connections/my-conn/repositories/repo", - ), - ).to.be.undefined; - expect(repo.parseConnectionName("foobar")).to.be.undefined; - }); - }); - - describe("extractRepoSlugFromUri", () => { - it("extracts repo from URI", () => { - const cloneUri = "https://github.com/user/repo.git"; - const repoSlug = repo.extractRepoSlugFromUri(cloneUri); - expect(repoSlug).to.equal("user/repo"); - }); - }); - - describe("generateRepositoryId", () => { - it("extracts repo from URI", () => { - const cloneUri = "https://github.com/user/repo.git"; - const repoSlug = repo.generateRepositoryId(cloneUri); - expect(repoSlug).to.equal("user-repo"); - }); - }); - - describe("connect GitHub repo", () => { - const sandbox: sinon.SinonSandbox = sinon.createSandbox(); - - let promptOnceStub: sinon.SinonStub; - let pollOperationStub: sinon.SinonStub; - let getConnectionStub: sinon.SinonStub; - let getRepositoryStub: sinon.SinonStub; - let createConnectionStub: sinon.SinonStub; - let serviceAccountHasRolesStub: sinon.SinonStub; - let createRepositoryStub: sinon.SinonStub; - let listAllLinkableGitRepositoriesStub: sinon.SinonStub; - let getProjectNumberStub: sinon.SinonStub; - let openInBrowserPopupStub: sinon.SinonStub; - let listConnectionsStub: sinon.SinonStub; - let fetchGitHubInstallationsStub: sinon.SinonStub; - - beforeEach(() => { - promptOnceStub = sandbox.stub(prompt, "promptOnce").throws("Unexpected promptOnce call"); - pollOperationStub = sandbox - .stub(poller, "pollOperation") - .throws("Unexpected pollOperation call"); - getConnectionStub = sandbox - .stub(devconnect, "getConnection") - .throws("Unexpected getConnection call"); - getRepositoryStub = sandbox - .stub(devconnect, "getGitRepositoryLink") - .throws("Unexpected getGitRepositoryLink call"); - createConnectionStub = sandbox - .stub(devconnect, "createConnection") - .throws("Unexpected createConnection call"); - serviceAccountHasRolesStub = sandbox.stub(rm, "serviceAccountHasRoles").resolves(true); - createRepositoryStub = sandbox - .stub(devconnect, "createGitRepositoryLink") - .throws("Unexpected createGitRepositoryLink call"); - listAllLinkableGitRepositoriesStub = sandbox - .stub(devconnect, "listAllLinkableGitRepositories") - .throws("Unexpected listAllLinkableGitRepositories call"); - sandbox.stub(utils, "openInBrowser").resolves(); - openInBrowserPopupStub = sandbox - .stub(utils, "openInBrowserPopup") - .throws("Unexpected openInBrowserPopup call"); - getProjectNumberStub = sandbox - .stub(srcUtils, "getProjectNumber") - .throws("Unexpected getProjectNumber call"); - listConnectionsStub = sandbox - .stub(devconnect, "listAllConnections") - .throws("Unexpected listAllConnections call"); - fetchGitHubInstallationsStub = sandbox - .stub(devconnect, "fetchGitHubInstallations") - .throws("Unexpected fetchGitHubInstallations call"); - }); - - afterEach(() => { - sandbox.verifyAndRestore(); - }); - - const connectionId = `apphosting-${location}`; - - const op = { - name: `projects/${projectId}/locations/${location}/connections/${connectionId}`, - done: true, - }; - const pendingConn = { - name: `projects/${projectId}/locations/${location}/connections/${connectionId}`, - disabled: false, - createTime: "0", - updateTime: "1", - installationState: { - stage: "PENDING_USER_OAUTH", - message: "pending", - actionUri: "https://google.com", - }, - reconciling: false, - }; - const completeConn = { - name: `projects/${projectId}/locations/${location}/connections/${connectionId}`, - disabled: false, - createTime: "0", - updateTime: "1", - installationState: { - stage: "COMPLETE", - message: "complete", - actionUri: "https://google.com", - }, - reconciling: false, - }; - const repos = { - repositories: [ - { - name: "repo0", - remoteUri: "https://github.com/test/repo0.git", - }, - { - name: "repo1", - remoteUri: "https://github.com/test/repo1.git", - }, - ], - }; - - const oauthConnectionId = `firebase-app-hosting-github-oauth`; - - const oauthConn = { - name: `projects/${projectId}/locations/${location}/connections/${oauthConnectionId}`, - disabled: false, - createTime: "0", - updateTime: "1", - installationState: { - stage: "COMPLETE", - message: "complete", - actionUri: "https://google.com", - }, - reconciling: false, - githubConfig: { - githubApp: "FIREBASE", - authorizerCredential: { - oauthTokenSecretVersion: "1", - username: "testUser", - }, - appInstallationId: "installationID", - installationUri: "http://uri", - }, - }; - - it("creates a connection if it doesn't exist", async () => { - getConnectionStub.onFirstCall().rejects(new FirebaseError("error", { status: 404 })); - getConnectionStub.onSecondCall().resolves(completeConn); - createConnectionStub.resolves(op); - pollOperationStub.resolves(pendingConn); - promptOnceStub.onFirstCall().resolves("any key"); - - await repo.getOrCreateConnection(projectId, location, connectionId); - expect(createConnectionStub).to.be.calledWith(projectId, location, connectionId); - }); - - it("checks if secret manager admin role is granted for developer connect P4SA when creating an oauth connection", async () => { - getConnectionStub.onFirstCall().rejects(new FirebaseError("error", { status: 404 })); - getConnectionStub.onSecondCall().resolves(completeConn); - createConnectionStub.resolves(op); - pollOperationStub.resolves(pendingConn); - promptOnceStub.resolves("any key"); - getProjectNumberStub.onFirstCall().resolves(projectId); - openInBrowserPopupStub.resolves({ url: "", cleanup: sandbox.stub() }); - - await repo.getOrCreateOauthConnection(projectId, location); - expect(serviceAccountHasRolesStub).to.be.calledWith( - projectId, - `service-${projectId}@gcp-sa-devconnect.iam.gserviceaccount.com`, - ["roles/secretmanager.admin"], - true, - ); - }); - - it("creates repository if it doesn't exist", async () => { - getConnectionStub.resolves(completeConn); - listAllLinkableGitRepositoriesStub.resolves(repos.repositories); - promptOnceStub.onFirstCall().resolves(repos.repositories[0].remoteUri); - getRepositoryStub.rejects(new FirebaseError("error", { status: 404 })); - createRepositoryStub.resolves({ name: "op" }); - pollOperationStub.resolves(repos.repositories[0]); - - await repo.getOrCreateRepository( - projectId, - location, - connectionId, - repos.repositories[0].remoteUri, - ); - expect(createRepositoryStub).to.be.calledWith( - projectId, - location, - connectionId, - "test-repo0", - repos.repositories[0].remoteUri, - ); - }); - - it("links a github repository without an existing oauth connection", async () => { - // linkGitHubRepository() - // -getOrCreateGithubConnectionWithSentinel() - // --getOrCreateOauthConnection - getConnectionStub.onFirstCall().rejects(new FirebaseError("error", { status: 404 })); // Oauth sentinel not yet created. - createConnectionStub.onFirstCall().resolves({ name: "op" }); // Poll on createsConnection(). - pollOperationStub.onFirstCall().resolves(oauthConn); // Polling returns the connection created. - getProjectNumberStub.onFirstCall().resolves(projectId); // Verifies the secret manager grant. - - // -getOrCreateGithubConnectionWithSentinel() - // promptGitHubInstallation fetches the installations. - fetchGitHubInstallationsStub.resolves([ - { - id: "installationID", - name: "main-user", - type: "user", - }, - ]); - - promptOnceStub.onFirstCall().resolves("installationID"); // Uses existing Github Account installation. - listConnectionsStub.resolves([oauthConn]); // getConnectionForInstallation() returns sentinel connection. - - // -- createFullyInstalledConnection - createConnectionStub.onSecondCall().resolves({ name: "op" }); // Poll on createsConnection(). - pollOperationStub.onSecondCall().resolves(pendingConn); // Polling returns the connection created. - promptOnceStub.onSecondCall().resolves("enter"); // Enter to signal setup finished. - getConnectionStub.onSecondCall().resolves(completeConn); // getConnection() returns a completed connection. - - // linkGitHubRepository() - // -promptCloneUri() - listAllLinkableGitRepositoriesStub.resolves(repos.repositories); // fetchRepositoryCloneUris() returns repos - promptOnceStub.onThirdCall().resolves(repos.repositories[0].remoteUri); // promptCloneUri() returns repo's clone uri. - - // linkGitHubRepository() - getConnectionStub.onThirdCall().resolves(completeConn); // getOrCreateConnection() returns a completed connection. - - // -getOrCreateRepository() - getRepositoryStub.rejects(new FirebaseError("error", { status: 404 })); // Repo not yet created. - createRepositoryStub.resolves({ name: "op" }); // Poll on createGitRepositoryLink(). - pollOperationStub.resolves(repos.repositories[0]); // Polling returns the gitRepoLink. - - const r = await repo.linkGitHubRepository(projectId, location); - expect(getConnectionStub).to.be.calledWith(projectId, location, oauthConnectionId); - expect(getConnectionStub).to.be.calledWith(projectId, location, connectionId); - expect(createConnectionStub).to.be.calledWith(projectId, location, oauthConnectionId); - expect(createConnectionStub).to.be.calledWithMatch( - projectId, - location, - /apphosting-github-conn-.*/g, - { - appInstallationId: "installationID", - authorizerCredential: oauthConn.githubConfig.authorizerCredential, - }, - ); - - expect(r).to.be.deep.equal(repos.repositories[0]); // Returns the correct repo. - }); - - it("links a github repository using a sentinel oauth connection", async () => { - // linkGitHubRepository() - // -getOrCreateGithubConnectionWithSentinel() - getConnectionStub.onFirstCall().resolves(oauthConn); // getOrCreateOauthConnection() Fetches oauth sentinel. - - // promptGitHubInstallation fetches the installations. - fetchGitHubInstallationsStub.resolves([ - { - id: "installationID", - name: "main-user", - type: "user", - }, - ]); - - promptOnceStub.onFirstCall().resolves("installationID"); // Uses existing Github Account installation. - listConnectionsStub.resolves([oauthConn]); // getConnectionForInstallation() returns sentinel connection. - createConnectionStub.onFirstCall().resolves({ name: "op" }); // Poll on createsConnection(). - pollOperationStub.onFirstCall().resolves(completeConn); // Polling returns the oauth stub connection created. - - // linkGitHubRepository() - // -promptCloneUri() - listAllLinkableGitRepositoriesStub.resolves(repos.repositories); // fetchRepositoryCloneUris() returns repos - promptOnceStub.onSecondCall().resolves(repos.repositories[0].remoteUri); // promptCloneUri() returns repo's clone uri. - - // linkGitHubRepository() - getConnectionStub.onSecondCall().resolves(completeConn); // getOrCreateConnection() returns a completed connection. - - // -getOrCreateRepository() - getRepositoryStub.rejects(new FirebaseError("error", { status: 404 })); // Repo not yet created. - createRepositoryStub.resolves({ name: "op" }); // Poll on createGitRepositoryLink(). - pollOperationStub.onSecondCall().resolves(repos.repositories[0]); // Polling returns the gitRepoLink. - - const r = await repo.linkGitHubRepository(projectId, location); - expect(getConnectionStub).to.be.calledWith(projectId, location, oauthConnectionId); - expect(getConnectionStub).to.be.calledWith(projectId, location, connectionId); - expect(createConnectionStub).to.be.calledOnce; - expect(createConnectionStub).to.be.calledWithMatch( - projectId, - location, - /apphosting-github-conn-.*/g, - { - appInstallationId: "installationID", - authorizerCredential: oauthConn.githubConfig.authorizerCredential, - }, - ); - - expect(r).to.be.deep.equal(repos.repositories[0]); // Returns the correct repo. - }); - - it("links a github repository with a new named connection", async () => { - const namedConnectionId = `apphosting-named-${location}`; - - const namedCompleteConn = { - name: `projects/${projectId}/locations/${location}/connections/${namedConnectionId}`, - disabled: false, - createTime: "0", - updateTime: "1", - installationState: { - stage: "COMPLETE", - message: "complete", - actionUri: "https://google.com", - }, - reconciling: false, - }; - - // linkGitHubRepository() - // -getOrCreateGithubConnectionWithSentinel() - getConnectionStub.onFirstCall().rejects(new FirebaseError("error", { status: 404 })); // Named connection does not exist. - getConnectionStub.onSecondCall().resolves(oauthConn); // Fetches oauth sentinel. - // promptGitHubInstallation fetches the installations. - fetchGitHubInstallationsStub.resolves([ - { - id: "installationID", - name: "main-user", - type: "user", - }, - ]); - promptOnceStub.onFirstCall().resolves("installationID"); // Uses existing Github Account installation. - listConnectionsStub.resolves([oauthConn]); // Installation has sentinel connection but not the named one. - - // --createFullyInstalledConnection - createConnectionStub.onFirstCall().resolves({ name: "op" }); // Poll on createsConnection(). - pollOperationStub.onFirstCall().resolves(namedCompleteConn); // Polling returns the connection created. - - // linkGitHubRepository() - // -promptCloneUri() - listAllLinkableGitRepositoriesStub.resolves(repos.repositories); // fetchRepositoryCloneUris() returns repos - promptOnceStub.onSecondCall().resolves(repos.repositories[0].remoteUri); // promptCloneUri() returns repo's clone uri. - - // linkGitHubRepository() - getConnectionStub.onThirdCall().resolves(namedCompleteConn); // getOrCreateConnection() returns a completed connection. - - // -getOrCreateRepository() - getRepositoryStub.rejects(new FirebaseError("error", { status: 404 })); // Repo not yet created. - createRepositoryStub.resolves({ name: "op" }); // Poll on createGitRepositoryLink(). - pollOperationStub.onSecondCall().resolves(repos.repositories[0]); // Polling returns the gitRepoLink. - - const r = await repo.linkGitHubRepository(projectId, location, namedConnectionId); - - expect(r).to.be.deep.equal(repos.repositories[0]); - expect(getConnectionStub).to.be.calledWith(projectId, location, oauthConnectionId); - expect(getConnectionStub).to.be.calledWith(projectId, location, namedConnectionId); - expect(createConnectionStub).to.be.calledWith(projectId, location, namedConnectionId, { - appInstallationId: "installationID", - authorizerCredential: oauthConn.githubConfig.authorizerCredential, - }); - }); - - it("reuses an existing named connection to link github repo", async () => { - const namedConnectionId = `apphosting-named-${location}`; - - const namedCompleteConn = { - name: `projects/${projectId}/locations/${location}/connections/${namedConnectionId}`, - disabled: false, - createTime: "0", - updateTime: "1", - installationState: { - stage: "COMPLETE", - message: "complete", - actionUri: "https://google.com", - }, - reconciling: false, - }; - - // linkGitHubRepository() - // -getOrCreateGithubConnectionWithSentinel() - getConnectionStub.onFirstCall().resolves(namedCompleteConn); // Named connection already exists. - - // -promptCloneUri() - listAllLinkableGitRepositoriesStub.resolves(repos.repositories); // fetchRepositoryCloneUris() returns repos - promptOnceStub.onFirstCall().resolves(repos.repositories[0].remoteUri); // Selects the repo's clone uri. - - // linkGitHubRepository() - getConnectionStub.onSecondCall().resolves(namedCompleteConn); // getOrCreateConnection() returns a completed connection. - - // -getOrCreateRepository() - getRepositoryStub.rejects(new FirebaseError("error", { status: 404 })); // Repo not yet created. - createRepositoryStub.resolves({ name: "op" }); // Poll on createGitRepositoryLink(). - pollOperationStub.resolves(repos.repositories[0]); // Polling returns the gitRepoLink. - - const r = await repo.linkGitHubRepository(projectId, location, namedConnectionId); - - expect(r).to.be.deep.equal(repos.repositories[0]); - expect(getConnectionStub).to.be.calledWith(projectId, location, namedConnectionId); - expect(getConnectionStub).to.not.be.calledWith(projectId, location, oauthConnectionId); - expect(listConnectionsStub).to.not.be.called; - expect(createConnectionStub).to.not.be.called; - }); - - it("re-uses existing repository it already exists", async () => { - getConnectionStub.resolves(completeConn); - listAllLinkableGitRepositoriesStub.resolves(repos.repositories); - promptOnceStub.onFirstCall().resolves(repos.repositories[0].remoteUri); - getRepositoryStub.resolves(repos.repositories[0]); - - const r = await repo.getOrCreateRepository( - projectId, - location, - connectionId, - repos.repositories[0].remoteUri, - ); - expect(r).to.be.deep.equal(repos.repositories[0]); - }); - }); - - describe("fetchRepositoryCloneUris", () => { - const sandbox: sinon.SinonSandbox = sinon.createSandbox(); - let listAllLinkableGitRepositoriesStub: sinon.SinonStub; - - beforeEach(() => { - listAllLinkableGitRepositoriesStub = sandbox - .stub(devconnect, "listAllLinkableGitRepositories") - .throws("Unexpected listAllLinkableGitRepositories call"); - }); - - afterEach(() => { - sandbox.verifyAndRestore(); - }); - - it("should fetch all linkable repositories from multiple connections", async () => { - const conn0 = mockConn("conn0"); - const repo0 = mockRepo("repo-0"); - const repo1 = mockRepo("repo-1"); - listAllLinkableGitRepositoriesStub.onFirstCall().resolves([repo0, repo1]); - - const repos = await repo.fetchRepositoryCloneUris(projectId, conn0); - - expect(repos.length).to.equal(2); - expect(repos).to.deep.equal([repo0.cloneUri, repo1.cloneUri]); - }); - }); - - describe("listAppHostingConnections", () => { - const sandbox: sinon.SinonSandbox = sinon.createSandbox(); - let listConnectionsStub: sinon.SinonStub; - - function extractId(name: string): string { - const parts = name.split("/"); - return parts.pop() ?? ""; - } - - beforeEach(() => { - listConnectionsStub = sandbox - .stub(devconnect, "listAllConnections") - .throws("Unexpected listAllConnections call"); - }); - - afterEach(() => { - sandbox.verifyAndRestore(); - }); - - it("filters out non-apphosting connections", async () => { - listConnectionsStub.resolves([ - mockConn("apphosting-github-conn-baddcafe"), - mockConn("hooray-conn"), - mockConn("apphosting-github-conn-deadbeef"), - mockConn("apphosting-github-oauth"), - ]); - - const conns = await repo.listAppHostingConnections(projectId, location); - expect(conns).to.have.length(2); - expect(conns.map((c) => extractId(c.name))).to.include.members([ - "apphosting-github-conn-baddcafe", - "apphosting-github-conn-deadbeef", - ]); - }); - }); - - describe("listValidInstallations", () => { - const sandbox: sinon.SinonSandbox = sinon.createSandbox(); - let fetchGitHubInstallationsStub: sinon.SinonStub; - - beforeEach(() => { - fetchGitHubInstallationsStub = sandbox - .stub(devconnect, "fetchGitHubInstallations") - .throws("Unexpected fetchGitHubInstallations call"); - }); - - afterEach(() => { - sandbox.verifyAndRestore(); - }); - - it("only lists organizations and authorizer github account", async () => { - const conn = mockConn("1"); - conn.githubConfig = { - authorizerCredential: { - oauthTokenSecretVersion: "blah", - username: "main-user", - }, - }; - - fetchGitHubInstallationsStub.resolves([ - { - id: "1", - name: "main-user", - type: "user", - }, - { - id: "2", - name: "org-1", - type: "organization", - }, - { - id: "3", - name: "org-3", - type: "organization", - }, - { - id: "4", - name: "some-other-user", - type: "user", - }, - { - id: "5", - name: "org-4", - type: "organization", - }, - ]); - - const installations = await repo.listValidInstallations(projectId, location, conn); - expect(installations).to.deep.equal([ - { - id: "1", - name: "main-user", - type: "user", - }, - { - id: "2", - name: "org-1", - type: "organization", - }, - { - id: "3", - name: "org-3", - type: "organization", - }, - { - id: "5", - name: "org-4", - type: "organization", - }, - ]); - }); - }); - - describe("getConnectionForInstallation", () => { - const sandbox: sinon.SinonSandbox = sinon.createSandbox(); - let listConnectionsStub: sinon.SinonStub; - - beforeEach(() => { - listConnectionsStub = sandbox - .stub(devconnect, "listAllConnections") - .throws("Unexpected listAllConnections call"); - }); - - afterEach(() => { - sandbox.verifyAndRestore(); - }); - - it("finds the matching connection for a given installation", async () => { - const mockConn1 = mockConn("apphosting-github-conn-1"); - const mockConn2 = mockConn("apphosting-github-conn-2"); - const mockConn3 = mockConn("apphosting-github-conn-3"); - const mockConn4 = mockConn("random-conn"); - - const installationToMatch = "installation-1"; - - mockConn1.githubConfig = { - appInstallationId: installationToMatch, - }; - - mockConn2.githubConfig = { - appInstallationId: "installation-2", - }; - - mockConn3.githubConfig = { - appInstallationId: "installation-3", - }; - - listConnectionsStub.resolves([mockConn1, mockConn2, mockConn3, mockConn4]); - - const matchingConnection = await repo.getConnectionForInstallation( - projectId, - location, - installationToMatch, - ); - expect(matchingConnection).to.deep.equal(mockConn1); - }); - - it("returns null if there is no matching connection for a given installation", async () => { - const mockConn1 = mockConn("apphosting-github-conn-1"); - const mockConn2 = mockConn("apphosting-github-conn-2"); - - const installationToMatch = "random-installation"; - - mockConn1.githubConfig = { - appInstallationId: "installation-1", - }; - - mockConn2.githubConfig = { - appInstallationId: "installation-2", - }; - - listConnectionsStub.resolves([mockConn1, mockConn2]); - - const matchingConnection = await repo.getConnectionForInstallation( - projectId, - location, - installationToMatch, - ); - expect(matchingConnection).to.be.null; - }); - }); - - describe("ensureSecretManagerAdminGrant", () => { - const sandbox: sinon.SinonSandbox = sinon.createSandbox(); - - let promptOnceStub: sinon.SinonStub; - let serviceAccountHasRolesStub: sinon.SinonStub; - let addServiceAccountToRolesStub: sinon.SinonStub; - let generateP4SAStub: sinon.SinonStub; - - beforeEach(() => { - promptOnceStub = sandbox.stub(prompt, "promptOnce").throws("Unexpected promptOnce call"); - serviceAccountHasRolesStub = sandbox.stub(rm, "serviceAccountHasRoles"); - sandbox.stub(srcUtils, "getProjectNumber").resolves(projectId); - addServiceAccountToRolesStub = sandbox.stub(rm, "addServiceAccountToRoles"); - generateP4SAStub = sandbox.stub(devconnect, "generateP4SA"); - }); - - afterEach(() => { - sandbox.verifyAndRestore(); - }); - - it("does not prompt user if the developer connect P4SA already has secretmanager.admin permissions", async () => { - serviceAccountHasRolesStub.resolves(true); - await repo.ensureSecretManagerAdminGrant(projectId); - - expect(serviceAccountHasRolesStub).calledWith( - projectId, - `service-${projectId}@gcp-sa-devconnect.iam.gserviceaccount.com`, - ["roles/secretmanager.admin"], - ); - expect(promptOnceStub).to.not.be.called; - }); - - it("prompts user if the developer connect P4SA does not have secretmanager.admin permissions", async () => { - serviceAccountHasRolesStub.resolves(false); - promptOnceStub.resolves(true); - addServiceAccountToRolesStub.resolves(); - - await repo.ensureSecretManagerAdminGrant(projectId); - - expect(serviceAccountHasRolesStub).calledWith( - projectId, - `service-${projectId}@gcp-sa-devconnect.iam.gserviceaccount.com`, - ["roles/secretmanager.admin"], - ); - - expect(promptOnceStub).to.be.called; - }); - - it("tries to generate developer connect P4SA if adding role throws an error", async () => { - serviceAccountHasRolesStub.resolves(false); - promptOnceStub.resolves(true); - generateP4SAStub.resolves(); - addServiceAccountToRolesStub.onFirstCall().throws({ code: 400, status: 400 }); - addServiceAccountToRolesStub.onSecondCall().resolves(); - - await repo.ensureSecretManagerAdminGrant(projectId); - - expect(serviceAccountHasRolesStub).calledWith( - projectId, - `service-${projectId}@gcp-sa-devconnect.iam.gserviceaccount.com`, - ["roles/secretmanager.admin"], - ).calledOnce; - expect(generateP4SAStub).calledOnce; - expect(promptOnceStub).to.be.called; - }); - }); - describe("promptGitHubBranch", () => { - const sandbox: sinon.SinonSandbox = sinon.createSandbox(); - - let promptOnceStub: sinon.SinonStub; - let listAllBranchesStub: sinon.SinonStub; - - beforeEach(() => { - promptOnceStub = sandbox.stub(prompt, "promptOnce").throws("Unexpected promptOnce call"); - listAllBranchesStub = sandbox - .stub(devconnect, "listAllBranches") - .throws("Unexpected listAllBranches call"); - }); - - afterEach(() => { - sandbox.verifyAndRestore(); - }); - - it("prompts user for branch", async () => { - listAllBranchesStub.returns(new Set(["main", "test1"])); - - promptOnceStub.onFirstCall().returns("main"); - const testRepoLink = { - name: "test", - cloneUri: "/test", - createTime: "", - updateTime: "", - deleteTime: "", - reconciling: false, - uid: "", - }; - await expect(repo.promptGitHubBranch(testRepoLink)).to.eventually.equal("main"); - }); - }); -}); diff --git a/src/apphosting/rollout.spec.ts b/src/apphosting/rollout.spec.ts index 8b943d1bfb5..8d1db5a2354 100644 --- a/src/apphosting/rollout.spec.ts +++ b/src/apphosting/rollout.spec.ts @@ -2,7 +2,7 @@ import * as sinon from "sinon"; import { expect } from "chai"; import { createRollout, orchestrateRollout } from "./rollout"; import * as devConnect from "../gcp/devConnect"; -import * as githubConnections from "../apphosting/githubConnections"; +import * as githubConnections from "./developer-connect/githubConnections"; import * as apphosting from "../gcp/apphosting"; import * as backend from "./backend"; import { FirebaseError } from "../error"; diff --git a/src/apphosting/rollout.ts b/src/apphosting/rollout.ts index 32b2bcb8d4e..3aeb9c65a2d 100644 --- a/src/apphosting/rollout.ts +++ b/src/apphosting/rollout.ts @@ -7,7 +7,7 @@ import { getGitHubCommit, GitHubCommitInfo, promptGitHubBranch, -} from "../apphosting/githubConnections"; +} from "./developer-connect/githubConnections"; import * as poller from "../operation-poller"; import { logBullet, sleep } from "../utils"; diff --git a/src/gcp/devConnect.ts b/src/gcp/devConnect.ts index 05143825d73..3f0ee97cadf 100644 --- a/src/gcp/devConnect.ts +++ b/src/gcp/devConnect.ts @@ -2,7 +2,6 @@ import { Client } from "../apiv2"; import { developerConnectOrigin, developerConnectP4SADomain } from "../api"; import { generateServiceIdentityAndPoll } from "./serviceusage"; import { FirebaseError } from "../error"; -import { extractRepoSlugFromUri } from "../apphosting/githubConnections"; const PAGE_SIZE_MAX = 1000; const LOCATION_OVERRIDE = process.env.FIREBASE_DEVELOPERCONNECT_LOCATION_OVERRIDE; @@ -138,6 +137,20 @@ export interface GitRepositoryLinkDetails { readToken: GitRepositoryLinkReadToken; } +/** + * Exported for unit testing. + * + * Example usage: + * extractRepoSlugFromURI("https://github.com/user/repo.git") => "user/repo" + */ +export function extractRepoSlugFromUri(cloneUri: string): string | undefined { + const match = /github.com\/(.+).git/.exec(cloneUri); + if (!match) { + return undefined; + } + return match[1]; +} + /** * Creates a Developer Connect Connection. */ diff --git a/src/gcp/devconnect.spec.ts b/src/gcp/devconnect.spec.ts index e11a14dee5f..8a128f28fbf 100644 --- a/src/gcp/devconnect.spec.ts +++ b/src/gcp/devconnect.spec.ts @@ -195,4 +195,12 @@ describe("developer connect", () => { }); }); }); + + describe("extractRepoSlugFromUri", () => { + it("extracts repo from URI", () => { + const cloneUri = "https://github.com/user/repo.git"; + const repoSlug = devconnect.extractRepoSlugFromUri(cloneUri); + expect(repoSlug).to.equal("user/repo"); + }); + }); });