From 5d42cbc1c82f03add8514242ae91712853b0d184 Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Tue, 14 Nov 2023 14:20:13 +0100 Subject: [PATCH 1/3] Also use credentials in non-canary mode We want users to be able to download databases from private/internal repositories without using canary mode. This will change the prompt to ask for credentials in non-canary mode as well. --- .../src/databases/github-database-module.ts | 58 ++++-- .../src/databases/github-database-prompt.ts | 44 +++-- .../databases/github-database-prompt.test.ts | 185 ++++++++---------- 3 files changed, 154 insertions(+), 133 deletions(-) diff --git a/extensions/ql-vscode/src/databases/github-database-module.ts b/extensions/ql-vscode/src/databases/github-database-module.ts index c709360fd0b..a1450d8164e 100644 --- a/extensions/ql-vscode/src/databases/github-database-module.ts +++ b/extensions/ql-vscode/src/databases/github-database-module.ts @@ -1,21 +1,19 @@ +import { window } from "vscode"; import { DisposableObject } from "../common/disposable-object"; import { App } from "../common/app"; import { findGitHubRepositoryForWorkspace } from "./github-repository-finder"; import { redactableError } from "../common/errors"; import { asError, getErrorMessage } from "../common/helpers-pure"; import { + askForGitHubDatabaseDownload, CodeqlDatabase, findGitHubDatabasesForRepository, - promptGitHubDatabaseDownload, + downloadDatabaseFromGitHub, } from "./github-database-prompt"; -import { - GitHubDatabaseConfig, - GitHubDatabaseConfigListener, - isCanary, -} from "../config"; -import { AppOctokit } from "../common/octokit"; +import { GitHubDatabaseConfig, GitHubDatabaseConfigListener } from "../config"; import { DatabaseManager } from "./local-databases"; import { CodeQLCliServer } from "../codeql-cli/cli"; +import { showNeverAskAgainDialog } from "../common/vscode/dialog"; export class GithubDatabaseModule extends DisposableObject { private readonly config: GitHubDatabaseConfig; @@ -93,11 +91,31 @@ export class GithubDatabaseModule extends DisposableObject { return; } - const credentials = isCanary() ? this.app.credentials : undefined; + const credentials = this.app.credentials; + + const hasAccessToken = !!(await credentials.getExistingAccessToken()); + + // If the user does not have an access token, ask whether they want to connect. + if (!hasAccessToken) { + const answer = await showNeverAskAgainDialog( + "This repository has an origin (GitHub) that may have one or more CodeQL databases. Connect to GitHub and download any existing databases?", + false, + "Connect", + "Not now", + "Never", + ); + + if (answer === "Not now" || answer === undefined) { + return; + } - const octokit = credentials - ? await credentials.getOctokit() - : new AppOctokit(); + if (answer === "Never") { + await this.config.setDownload("never"); + return; + } + } + + const octokit = await credentials.getOctokit(); let databases: CodeqlDatabase[]; try { @@ -121,15 +139,29 @@ export class GithubDatabaseModule extends DisposableObject { } if (databases.length === 0) { + // If the user didn't have an access token, they have already been prompted, + // so we should give feedback. + if (!hasAccessToken) { + void window.showInformationMessage( + "The GitHub repository does not have any CodeQL databases.", + ); + } + return; } - void promptGitHubDatabaseDownload( + // If the user already had an access token, first ask if they even want to download the DB. + if (hasAccessToken) { + if (!(await askForGitHubDatabaseDownload(databases, this.config))) { + return; + } + } + + await downloadDatabaseFromGitHub( octokit, githubRepository.owner, githubRepository.name, databases, - this.config, this.databaseManager, this.databaseStoragePath, this.cliServer, diff --git a/extensions/ql-vscode/src/databases/github-database-prompt.ts b/extensions/ql-vscode/src/databases/github-database-prompt.ts index f5cd79450dd..4a1eed799e4 100644 --- a/extensions/ql-vscode/src/databases/github-database-prompt.ts +++ b/extensions/ql-vscode/src/databases/github-database-prompt.ts @@ -30,20 +30,13 @@ export async function findGitHubDatabasesForRepository( } /** - * Prompt the user to download a database from GitHub. This is a blocking method, so this should - * almost never be called with `await`. + * Ask whether the user wants to download a database from GitHub. + * @return true if the user wants to download a database, false otherwise. */ -export async function promptGitHubDatabaseDownload( - octokit: Octokit, - owner: string, - repo: string, +export async function askForGitHubDatabaseDownload( databases: CodeqlDatabase[], config: GitHubDatabaseConfig, - databaseManager: DatabaseManager, - storagePath: string, - cliServer: CodeQLCliServer, - commandManager: AppCommandManager, -): Promise { +): Promise { const languages = databases.map((database) => database.language); const databasesMessage = @@ -57,26 +50,45 @@ export async function promptGitHubDatabaseDownload( const connectMessage = databases.length === 1 - ? `Connect to GitHub and download the existing database?` - : `Connect to GitHub and download any existing databases?`; + ? `Download the existing database from GitHub?` + : `Download any existing databases from GitHub?`; const answer = await showNeverAskAgainDialog( `${databasesMessage} ${connectMessage}`, false, - "Connect", + "Download", "Not now", "Never", ); if (answer === "Not now" || answer === undefined) { - return; + return false; } if (answer === "Never") { await config.setDownload("never"); - return; + return false; } + return true; +} + +/** + * Download a database from GitHub by asking the user for a language and then + * downloading the database for that language. + */ +export async function downloadDatabaseFromGitHub( + octokit: Octokit, + owner: string, + repo: string, + databases: CodeqlDatabase[], + databaseManager: DatabaseManager, + storagePath: string, + cliServer: CodeQLCliServer, + commandManager: AppCommandManager, +): Promise { + const languages = databases.map((database) => database.language); + const language = await promptForLanguage(languages, undefined); if (!language) { return; diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-prompt.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-prompt.test.ts index 5790ef803a0..470d804cc81 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-prompt.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-prompt.test.ts @@ -2,8 +2,9 @@ import { faker } from "@faker-js/faker"; import { Octokit } from "@octokit/rest"; import { mockedObject } from "../../utils/mocking.helpers"; import { + askForGitHubDatabaseDownload, CodeqlDatabase, - promptGitHubDatabaseDownload, + downloadDatabaseFromGitHub, } from "../../../../src/databases/github-database-prompt"; import { DatabaseManager } from "../../../../src/databases/local-databases"; import { GitHubDatabaseConfig } from "../../../../src/config"; @@ -12,13 +13,86 @@ import { createMockCommandManager } from "../../../__mocks__/commandsMock"; import * as databaseFetcher from "../../../../src/databases/database-fetcher"; import * as dialog from "../../../../src/common/vscode/dialog"; -describe("promptGitHubDatabaseDownload", () => { +describe("askForGitHubDatabaseDownload", () => { + const setDownload = jest.fn(); + let config: GitHubDatabaseConfig; + + const databases = [ + mockedObject({ + language: "swift", + url: faker.internet.url({ + protocol: "https", + }), + }), + ]; + + let showNeverAskAgainDialogSpy: jest.SpiedFunction< + typeof dialog.showNeverAskAgainDialog + >; + + beforeEach(() => { + config = mockedObject({ + setDownload, + }); + + showNeverAskAgainDialogSpy = jest + .spyOn(dialog, "showNeverAskAgainDialog") + .mockResolvedValue("Connect"); + }); + + describe("when answering not now to prompt", () => { + beforeEach(() => { + showNeverAskAgainDialogSpy.mockResolvedValue("Not now"); + }); + + it("returns false", async () => { + expect(await askForGitHubDatabaseDownload(databases, config)).toEqual( + false, + ); + + expect(setDownload).not.toHaveBeenCalled(); + }); + }); + + describe("when cancelling prompt", () => { + beforeEach(() => { + showNeverAskAgainDialogSpy.mockResolvedValue(undefined); + }); + + it("returns false", async () => { + expect(await askForGitHubDatabaseDownload(databases, config)).toEqual( + false, + ); + + expect(setDownload).not.toHaveBeenCalled(); + }); + }); + + describe("when answering never to prompt", () => { + beforeEach(() => { + showNeverAskAgainDialogSpy.mockResolvedValue("Never"); + }); + + it("returns false", async () => { + expect(await askForGitHubDatabaseDownload(databases, config)).toEqual( + false, + ); + }); + + it("sets the config to never", async () => { + await askForGitHubDatabaseDownload(databases, config); + + expect(setDownload).toHaveBeenCalledWith("never"); + }); + }); +}); + +describe("downloadDatabaseFromGitHub", () => { let octokit: Octokit; const owner = "github"; const repo = "codeql"; let databaseManager: DatabaseManager; - const setDownload = jest.fn(); - let config: GitHubDatabaseConfig; + const storagePath = "/a/b/c/d"; let cliServer: CodeQLCliServer; const commandManager = createMockCommandManager(); @@ -35,9 +109,6 @@ describe("promptGitHubDatabaseDownload", () => { }), ]; - let showNeverAskAgainDialogSpy: jest.SpiedFunction< - typeof dialog.showNeverAskAgainDialog - >; let promptForLanguageSpy: jest.SpiedFunction< typeof databaseFetcher.promptForLanguage >; @@ -48,14 +119,8 @@ describe("promptGitHubDatabaseDownload", () => { beforeEach(() => { octokit = mockedObject({}); databaseManager = mockedObject({}); - config = mockedObject({ - setDownload, - }); cliServer = mockedObject({}); - showNeverAskAgainDialogSpy = jest - .spyOn(dialog, "showNeverAskAgainDialog") - .mockResolvedValue("Connect"); promptForLanguageSpy = jest .spyOn(databaseFetcher, "promptForLanguage") .mockResolvedValue(databases[0].language); @@ -65,12 +130,11 @@ describe("promptGitHubDatabaseDownload", () => { }); it("downloads the database", async () => { - await promptGitHubDatabaseDownload( + await downloadDatabaseFromGitHub( octokit, owner, repo, databases, - config, databaseManager, storagePath, cliServer, @@ -94,90 +158,6 @@ describe("promptGitHubDatabaseDownload", () => { false, ); expect(promptForLanguageSpy).toHaveBeenCalledWith(["swift"], undefined); - expect(config.setDownload).not.toHaveBeenCalled(); - }); - - describe("when answering not now to prompt", () => { - beforeEach(() => { - showNeverAskAgainDialogSpy.mockResolvedValue("Not now"); - }); - - it("does not download the database", async () => { - await promptGitHubDatabaseDownload( - octokit, - owner, - repo, - databases, - config, - databaseManager, - storagePath, - cliServer, - commandManager, - ); - - expect(downloadGitHubDatabaseFromUrlSpy).not.toHaveBeenCalled(); - }); - }); - - describe("when cancelling prompt", () => { - beforeEach(() => { - showNeverAskAgainDialogSpy.mockResolvedValue(undefined); - }); - - it("does not download the database", async () => { - await promptGitHubDatabaseDownload( - octokit, - owner, - repo, - databases, - config, - databaseManager, - storagePath, - cliServer, - commandManager, - ); - - expect(downloadGitHubDatabaseFromUrlSpy).not.toHaveBeenCalled(); - }); - }); - - describe("when answering never to prompt", () => { - beforeEach(() => { - showNeverAskAgainDialogSpy.mockResolvedValue("Never"); - }); - - it("does not download the database", async () => { - await promptGitHubDatabaseDownload( - octokit, - owner, - repo, - databases, - config, - databaseManager, - storagePath, - cliServer, - commandManager, - ); - - expect(downloadGitHubDatabaseFromUrlSpy).not.toHaveBeenCalled(); - }); - - it('sets the config to "never"', async () => { - await promptGitHubDatabaseDownload( - octokit, - owner, - repo, - databases, - config, - databaseManager, - storagePath, - cliServer, - commandManager, - ); - - expect(config.setDownload).toHaveBeenCalledTimes(1); - expect(config.setDownload).toHaveBeenCalledWith("never"); - }); }); describe("when not selecting language", () => { @@ -186,12 +166,11 @@ describe("promptGitHubDatabaseDownload", () => { }); it("does not download the database", async () => { - await promptGitHubDatabaseDownload( + await downloadDatabaseFromGitHub( octokit, owner, repo, databases, - config, databaseManager, storagePath, cliServer, @@ -229,12 +208,11 @@ describe("promptGitHubDatabaseDownload", () => { }); it("downloads the correct database", async () => { - await promptGitHubDatabaseDownload( + await downloadDatabaseFromGitHub( octokit, owner, repo, databases, - config, databaseManager, storagePath, cliServer, @@ -261,7 +239,6 @@ describe("promptGitHubDatabaseDownload", () => { ["swift", "go"], undefined, ); - expect(config.setDownload).not.toHaveBeenCalled(); }); }); }); From 9dd061b2c8a2fc604ccc6675a079ab5c83918d60 Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Mon, 20 Nov 2023 13:37:05 +0100 Subject: [PATCH 2/3] Rename github-database-prompt to github-database-download --- .../{github-database-prompt.ts => github-database-download.ts} | 0 extensions/ql-vscode/src/databases/github-database-module.ts | 2 +- ...database-prompt.test.ts => github-database-download.test.ts} | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename extensions/ql-vscode/src/databases/{github-database-prompt.ts => github-database-download.ts} (100%) rename extensions/ql-vscode/test/vscode-tests/no-workspace/databases/{github-database-prompt.test.ts => github-database-download.test.ts} (99%) diff --git a/extensions/ql-vscode/src/databases/github-database-prompt.ts b/extensions/ql-vscode/src/databases/github-database-download.ts similarity index 100% rename from extensions/ql-vscode/src/databases/github-database-prompt.ts rename to extensions/ql-vscode/src/databases/github-database-download.ts diff --git a/extensions/ql-vscode/src/databases/github-database-module.ts b/extensions/ql-vscode/src/databases/github-database-module.ts index 59cf2d596c6..b7268bfdbff 100644 --- a/extensions/ql-vscode/src/databases/github-database-module.ts +++ b/extensions/ql-vscode/src/databases/github-database-module.ts @@ -9,7 +9,7 @@ import { CodeqlDatabase, downloadDatabaseFromGitHub, findGitHubDatabasesForRepository, -} from "./github-database-prompt"; +} from "./github-database-download"; import { GitHubDatabaseConfig, GitHubDatabaseConfigListener } from "../config"; import { DatabaseManager } from "./local-databases"; import { CodeQLCliServer } from "../codeql-cli/cli"; diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-prompt.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-download.test.ts similarity index 99% rename from extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-prompt.test.ts rename to extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-download.test.ts index 470d804cc81..a7da5aac504 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-prompt.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-download.test.ts @@ -5,7 +5,7 @@ import { askForGitHubDatabaseDownload, CodeqlDatabase, downloadDatabaseFromGitHub, -} from "../../../../src/databases/github-database-prompt"; +} from "../../../../src/databases/github-database-download"; import { DatabaseManager } from "../../../../src/databases/local-databases"; import { GitHubDatabaseConfig } from "../../../../src/config"; import { CodeQLCliServer } from "../../../../src/codeql-cli/cli"; From b83ef4ed6875a9be1d08ad2567e25986473bee8c Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Mon, 20 Nov 2023 14:20:48 +0100 Subject: [PATCH 3/3] Split up listing of databases to separate function --- .../src/databases/github-database-api.ts | 110 ++++++ .../src/databases/github-database-download.ts | 18 +- .../src/databases/github-database-module.ts | 48 +-- .../databases/github-database-api.test.ts | 345 ++++++++++++++++++ .../github-database-download.test.ts | 2 +- 5 files changed, 471 insertions(+), 52 deletions(-) create mode 100644 extensions/ql-vscode/src/databases/github-database-api.ts create mode 100644 extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-api.test.ts diff --git a/extensions/ql-vscode/src/databases/github-database-api.ts b/extensions/ql-vscode/src/databases/github-database-api.ts new file mode 100644 index 00000000000..e9d8908bda9 --- /dev/null +++ b/extensions/ql-vscode/src/databases/github-database-api.ts @@ -0,0 +1,110 @@ +import { RequestError } from "@octokit/request-error"; +import { Octokit } from "@octokit/rest"; +import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; +import { showNeverAskAgainDialog } from "../common/vscode/dialog"; +import { GitHubDatabaseConfig } from "../config"; +import { Credentials } from "../common/authentication"; +import { AppOctokit } from "../common/octokit"; + +export type CodeqlDatabase = + RestEndpointMethodTypes["codeScanning"]["listCodeqlDatabases"]["response"]["data"][number]; + +/** + * Ask the user if they want to connect to GitHub to download CodeQL databases. + * This should be used when the user does not have an access token and should + * be followed by an access token prompt. + */ +async function askForGitHubConnect( + config: GitHubDatabaseConfig, +): Promise { + const answer = await showNeverAskAgainDialog( + "This repository has an origin (GitHub) that may have one or more CodeQL databases. Connect to GitHub and download any existing databases?", + false, + "Connect", + "Not now", + "Never", + ); + + if (answer === "Not now" || answer === undefined) { + return false; + } + + if (answer === "Never") { + await config.setDownload("never"); + return false; + } + + return true; +} + +export type ListDatabasesResult = { + /** + * Whether the user has been prompted for credentials. This can be used to determine + * follow-up actions based on whether the user has already had any feedback. + */ + promptedForCredentials: boolean; + databases: CodeqlDatabase[]; + octokit: Octokit; +}; + +/** + * List CodeQL databases for a GitHub repository. + * + * This will first try to fetch the CodeQL databases for the repository with + * existing credentials (or none if there are none). If that fails, it will + * prompt the user to connect to GitHub and try again. + * + * If the user does not want to connect to GitHub, this will return `undefined`. + */ +export async function listDatabases( + owner: string, + repo: string, + credentials: Credentials, + config: GitHubDatabaseConfig, +): Promise { + const hasAccessToken = !!(await credentials.getExistingAccessToken()); + + let octokit = hasAccessToken + ? await credentials.getOctokit() + : new AppOctokit(); + + let promptedForCredentials = false; + + let databases: CodeqlDatabase[]; + try { + const response = await octokit.rest.codeScanning.listCodeqlDatabases({ + owner, + repo, + }); + databases = response.data; + } catch (e) { + // If we get a 404 when we don't have an access token, it might be because + // the repository is private/internal. Therefore, we should ask the user + // whether they want to connect to GitHub and try again. + if (e instanceof RequestError && e.status === 404 && !hasAccessToken) { + // Check whether the user wants to connect to GitHub + if (!(await askForGitHubConnect(config))) { + return; + } + + // Prompt for credentials + octokit = await credentials.getOctokit(); + + promptedForCredentials = true; + + const response = await octokit.rest.codeScanning.listCodeqlDatabases({ + owner, + repo, + }); + databases = response.data; + } else { + throw e; + } + } + + return { + promptedForCredentials, + databases, + octokit, + }; +} diff --git a/extensions/ql-vscode/src/databases/github-database-download.ts b/extensions/ql-vscode/src/databases/github-database-download.ts index dc30892d35a..e19debf79cb 100644 --- a/extensions/ql-vscode/src/databases/github-database-download.ts +++ b/extensions/ql-vscode/src/databases/github-database-download.ts @@ -1,5 +1,4 @@ import { window } from "vscode"; -import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; import { Octokit } from "@octokit/rest"; import { showNeverAskAgainDialog } from "../common/vscode/dialog"; import { getLanguageDisplayName } from "../common/query-language"; @@ -12,22 +11,7 @@ import { DatabaseManager } from "./local-databases"; import { CodeQLCliServer } from "../codeql-cli/cli"; import { AppCommandManager } from "../common/commands"; import { GitHubDatabaseConfig } from "../config"; - -export type CodeqlDatabase = - RestEndpointMethodTypes["codeScanning"]["listCodeqlDatabases"]["response"]["data"][number]; - -export async function findGitHubDatabasesForRepository( - octokit: Octokit, - owner: string, - repo: string, -): Promise { - const response = await octokit.rest.codeScanning.listCodeqlDatabases({ - owner, - repo, - }); - - return response.data; -} +import type { CodeqlDatabase } from "./github-database-api"; /** * Ask whether the user wants to download a database from GitHub. diff --git a/extensions/ql-vscode/src/databases/github-database-module.ts b/extensions/ql-vscode/src/databases/github-database-module.ts index b7268bfdbff..e6fe8d1b654 100644 --- a/extensions/ql-vscode/src/databases/github-database-module.ts +++ b/extensions/ql-vscode/src/databases/github-database-module.ts @@ -6,14 +6,12 @@ import { redactableError } from "../common/errors"; import { asError, getErrorMessage } from "../common/helpers-pure"; import { askForGitHubDatabaseDownload, - CodeqlDatabase, downloadDatabaseFromGitHub, - findGitHubDatabasesForRepository, } from "./github-database-download"; import { GitHubDatabaseConfig, GitHubDatabaseConfigListener } from "../config"; import { DatabaseManager } from "./local-databases"; import { CodeQLCliServer } from "../codeql-cli/cli"; -import { showNeverAskAgainDialog } from "../common/vscode/dialog"; +import { listDatabases, ListDatabasesResult } from "./github-database-api"; export class GithubDatabaseModule extends DisposableObject { private readonly config: GitHubDatabaseConfig; @@ -91,38 +89,13 @@ export class GithubDatabaseModule extends DisposableObject { return; } - const credentials = this.app.credentials; - - const hasAccessToken = !!(await credentials.getExistingAccessToken()); - - // If the user does not have an access token, ask whether they want to connect. - if (!hasAccessToken) { - const answer = await showNeverAskAgainDialog( - "This repository has an origin (GitHub) that may have one or more CodeQL databases. Connect to GitHub and download any existing databases?", - false, - "Connect", - "Not now", - "Never", - ); - - if (answer === "Not now" || answer === undefined) { - return; - } - - if (answer === "Never") { - await this.config.setDownload("never"); - return; - } - } - - const octokit = await credentials.getOctokit(); - - let databases: CodeqlDatabase[]; + let result: ListDatabasesResult | undefined; try { - databases = await findGitHubDatabasesForRepository( - octokit, + result = await listDatabases( githubRepository.owner, githubRepository.name, + this.app.credentials, + this.config, ); } catch (e) { this.app.telemetry?.sendError( @@ -138,10 +111,17 @@ export class GithubDatabaseModule extends DisposableObject { return; } + // This means the user didn't want to connect, so we can just return. + if (result === undefined) { + return; + } + + const { databases, promptedForCredentials, octokit } = result; + if (databases.length === 0) { // If the user didn't have an access token, they have already been prompted, // so we should give feedback. - if (!hasAccessToken) { + if (promptedForCredentials) { void window.showInformationMessage( "The GitHub repository does not have any CodeQL databases.", ); @@ -151,7 +131,7 @@ export class GithubDatabaseModule extends DisposableObject { } // If the user already had an access token, first ask if they even want to download the DB. - if (hasAccessToken) { + if (!promptedForCredentials) { if (!(await askForGitHubDatabaseDownload(databases, this.config))) { return; } diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-api.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-api.test.ts new file mode 100644 index 00000000000..ce71647acb1 --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-api.test.ts @@ -0,0 +1,345 @@ +import { + mockedObject, + mockedOctokitFunction, +} from "../../utils/mocking.helpers"; +import { GitHubDatabaseConfig } from "../../../../src/config"; +import * as dialog from "../../../../src/common/vscode/dialog"; +import { listDatabases } from "../../../../src/databases/github-database-api"; +import { Credentials } from "../../../../src/common/authentication"; +import * as Octokit from "@octokit/rest"; +import { AppOctokit } from "../../../../src/common/octokit"; +import { RequestError } from "@octokit/request-error"; + +// Mock the AppOctokit constructor to ensure we aren't making any network requests +jest.mock("../../../../src/common/octokit", () => ({ + AppOctokit: jest.fn(), +})); +const appMockListCodeqlDatabases = mockedOctokitFunction< + "codeScanning", + "listCodeqlDatabases" +>(); +const appOctokit = mockedObject({ + rest: { + codeScanning: { + listCodeqlDatabases: appMockListCodeqlDatabases, + }, + }, +}); +beforeEach(() => { + (AppOctokit as unknown as jest.Mock).mockImplementation(() => appOctokit); +}); + +describe("listDatabases", () => { + const owner = "github"; + const repo = "codeql"; + + const setDownload = jest.fn(); + let config: GitHubDatabaseConfig; + let credentials: Credentials; + + const mockListCodeqlDatabases = mockedOctokitFunction< + "codeScanning", + "listCodeqlDatabases" + >(); + const octokit = mockedObject({ + rest: { + codeScanning: { + listCodeqlDatabases: mockListCodeqlDatabases, + }, + }, + }); + + const databases = [ + { + id: 1495869, + name: "csharp-database", + language: "csharp", + uploader: {}, + content_type: "application/zip", + state: "uploaded", + size: 55599715, + created_at: "2022-03-24T10:46:24Z", + updated_at: "2022-03-24T10:46:27Z", + url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/csharp", + }, + ]; + + const successfulMockApiResponse = { + data: databases, + }; + + let showNeverAskAgainDialogSpy: jest.SpiedFunction< + typeof dialog.showNeverAskAgainDialog + >; + + beforeEach(() => { + config = mockedObject({ + setDownload, + }); + + mockListCodeqlDatabases.mockResolvedValue(successfulMockApiResponse); + + showNeverAskAgainDialogSpy = jest + .spyOn(dialog, "showNeverAskAgainDialog") + .mockResolvedValue("Connect"); + }); + + describe("when the user has an access token", () => { + beforeEach(() => { + credentials = mockedObject({ + getExistingAccessToken: () => "ghp_xxx", + getOctokit: () => octokit, + }); + }); + + it("returns the databases", async () => { + expect(await listDatabases(owner, repo, credentials, config)).toEqual({ + databases, + promptedForCredentials: false, + octokit, + }); + }); + + describe("when the request fails with a 404", () => { + beforeEach(() => { + mockListCodeqlDatabases.mockRejectedValue( + new RequestError("Not found", 404, { + request: { + method: "GET", + url: "", + headers: {}, + }, + response: { + status: 404, + headers: {}, + url: "", + data: {}, + }, + }), + ); + }); + + it("throws an error", async () => { + await expect( + listDatabases(owner, repo, credentials, config), + ).rejects.toThrowError("Not found"); + }); + }); + + describe("when the request fails with a 500", () => { + beforeEach(() => { + mockListCodeqlDatabases.mockRejectedValue( + new RequestError("Internal server error", 500, { + request: { + method: "GET", + url: "", + headers: {}, + }, + response: { + status: 500, + headers: {}, + url: "", + data: {}, + }, + }), + ); + }); + + it("throws an error", async () => { + await expect( + listDatabases(owner, repo, credentials, config), + ).rejects.toThrowError("Internal server error"); + }); + }); + }); + + describe("when the user does not have an access token", () => { + describe("when the repo is public", () => { + beforeEach(() => { + credentials = mockedObject({ + getExistingAccessToken: () => undefined, + }); + + mockListCodeqlDatabases.mockResolvedValue(undefined); + appMockListCodeqlDatabases.mockResolvedValue(successfulMockApiResponse); + }); + + it("returns the databases", async () => { + const result = await listDatabases(owner, repo, credentials, config); + expect(result).toEqual({ + databases, + promptedForCredentials: false, + octokit: appOctokit, + }); + expect(showNeverAskAgainDialogSpy).not.toHaveBeenCalled(); + }); + + describe("when the request fails with a 500", () => { + beforeEach(() => { + appMockListCodeqlDatabases.mockRejectedValue( + new RequestError("Internal server error", 500, { + request: { + method: "GET", + url: "", + headers: {}, + }, + response: { + status: 500, + headers: {}, + url: "", + data: {}, + }, + }), + ); + }); + + it("throws an error", async () => { + await expect( + listDatabases(owner, repo, credentials, config), + ).rejects.toThrowError("Internal server error"); + expect(mockListCodeqlDatabases).not.toHaveBeenCalled(); + }); + }); + }); + + describe("when the repo is private", () => { + beforeEach(() => { + credentials = mockedObject({ + getExistingAccessToken: () => undefined, + getOctokit: () => octokit, + }); + + appMockListCodeqlDatabases.mockRejectedValue( + new RequestError("Not found", 404, { + request: { + method: "GET", + url: "", + headers: {}, + }, + response: { + status: 404, + headers: {}, + url: "", + data: {}, + }, + }), + ); + }); + + describe("when answering connect to prompt", () => { + beforeEach(() => { + showNeverAskAgainDialogSpy.mockResolvedValue("Connect"); + }); + + it("returns the databases", async () => { + const result = await listDatabases(owner, repo, credentials, config); + expect(result).toEqual({ + databases, + promptedForCredentials: true, + octokit, + }); + expect(showNeverAskAgainDialogSpy).toHaveBeenCalled(); + expect(appMockListCodeqlDatabases).toHaveBeenCalled(); + expect(mockListCodeqlDatabases).toHaveBeenCalled(); + }); + + describe("when the request fails with a 404", () => { + beforeEach(() => { + mockListCodeqlDatabases.mockRejectedValue( + new RequestError("Not found", 404, { + request: { + method: "GET", + url: "", + headers: {}, + }, + response: { + status: 404, + headers: {}, + url: "", + data: {}, + }, + }), + ); + }); + + it("throws an error", async () => { + await expect( + listDatabases(owner, repo, credentials, config), + ).rejects.toThrowError("Not found"); + }); + }); + + describe("when the request fails with a 500", () => { + beforeEach(() => { + mockListCodeqlDatabases.mockRejectedValue( + new RequestError("Internal server error", 500, { + request: { + method: "GET", + url: "", + headers: {}, + }, + response: { + status: 500, + headers: {}, + url: "", + data: {}, + }, + }), + ); + }); + + it("throws an error", async () => { + await expect( + listDatabases(owner, repo, credentials, config), + ).rejects.toThrowError("Internal server error"); + }); + }); + }); + + describe("when cancelling prompt", () => { + beforeEach(() => { + showNeverAskAgainDialogSpy.mockResolvedValue(undefined); + }); + + it("returns undefined", async () => { + const result = await listDatabases(owner, repo, credentials, config); + expect(result).toEqual(undefined); + expect(showNeverAskAgainDialogSpy).toHaveBeenCalled(); + expect(appMockListCodeqlDatabases).toHaveBeenCalled(); + expect(mockListCodeqlDatabases).not.toHaveBeenCalled(); + expect(setDownload).not.toHaveBeenCalled(); + }); + }); + + describe("when answering not now to prompt", () => { + beforeEach(() => { + showNeverAskAgainDialogSpy.mockResolvedValue("Not now"); + }); + + it("returns undefined", async () => { + const result = await listDatabases(owner, repo, credentials, config); + expect(result).toEqual(undefined); + expect(showNeverAskAgainDialogSpy).toHaveBeenCalled(); + expect(appMockListCodeqlDatabases).toHaveBeenCalled(); + expect(mockListCodeqlDatabases).not.toHaveBeenCalled(); + expect(setDownload).not.toHaveBeenCalled(); + }); + }); + + describe("when answering never to prompt", () => { + beforeEach(() => { + showNeverAskAgainDialogSpy.mockResolvedValue("Never"); + }); + + it("returns undefined and sets the config to 'never'", async () => { + const result = await listDatabases(owner, repo, credentials, config); + expect(result).toEqual(undefined); + expect(showNeverAskAgainDialogSpy).toHaveBeenCalled(); + expect(appMockListCodeqlDatabases).toHaveBeenCalled(); + expect(mockListCodeqlDatabases).not.toHaveBeenCalled(); + expect(setDownload).toHaveBeenCalledWith("never"); + }); + }); + }); + }); +}); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-download.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-download.test.ts index a7da5aac504..601a7dc318d 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-download.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-database-download.test.ts @@ -3,7 +3,6 @@ import { Octokit } from "@octokit/rest"; import { mockedObject } from "../../utils/mocking.helpers"; import { askForGitHubDatabaseDownload, - CodeqlDatabase, downloadDatabaseFromGitHub, } from "../../../../src/databases/github-database-download"; import { DatabaseManager } from "../../../../src/databases/local-databases"; @@ -12,6 +11,7 @@ import { CodeQLCliServer } from "../../../../src/codeql-cli/cli"; import { createMockCommandManager } from "../../../__mocks__/commandsMock"; import * as databaseFetcher from "../../../../src/databases/database-fetcher"; import * as dialog from "../../../../src/common/vscode/dialog"; +import { CodeqlDatabase } from "../../../../src/databases/github-database-api"; describe("askForGitHubDatabaseDownload", () => { const setDownload = jest.fn();