Skip to content

Commit

Permalink
Showing 12 changed files with 590 additions and 66 deletions.
41 changes: 9 additions & 32 deletions lib/database-upload.test.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/database-upload.test.js.map

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions lib/feature-flags.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions lib/feature-flags.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

145 changes: 145 additions & 0 deletions lib/feature-flags.test.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions lib/feature-flags.test.js.map
26 changes: 25 additions & 1 deletion lib/testing-utils.js
2 changes: 1 addition & 1 deletion lib/testing-utils.js.map
37 changes: 6 additions & 31 deletions src/database-upload.test.ts
@@ -11,9 +11,13 @@ import { setCodeQL } from "./codeql";
import { Config } from "./config-utils";
import { uploadDatabases } from "./database-upload";
import { Language } from "./languages";
import { Logger } from "./logging";
import { RepositoryNwo } from "./repository";
import { setupActionsVars, setupTests } from "./testing-utils";
import {
getRecordingLogger,
LoggedMessage,
setupActionsVars,
setupTests,
} from "./testing-utils";
import {
GitHubVariant,
HTTPError,
@@ -51,35 +55,6 @@ function getTestConfig(tmpDir: string): Config {
};
}

interface LoggedMessage {
type: "debug" | "info" | "warning" | "error";
message: string | Error;
}

function getRecordingLogger(messages: LoggedMessage[]): Logger {
return {
debug: (message: string) => {
messages.push({ type: "debug", message });
console.debug(message);
},
info: (message: string) => {
messages.push({ type: "info", message });
console.info(message);
},
warning: (message: string | Error) => {
messages.push({ type: "warning", message });
console.warn(message);
},
error: (message: string | Error) => {
messages.push({ type: "error", message });
console.error(message);
},
isDebug: () => true,
startGroup: () => undefined,
endGroup: () => undefined,
};
}

function mockHttpRequests(
optInStatusCode: number,
useUploadDomain?: boolean,
202 changes: 202 additions & 0 deletions src/feature-flags.test.ts
@@ -0,0 +1,202 @@
import * as github from "@actions/github";
import test from "ava";
import * as sinon from "sinon";

import * as apiClient from "./api-client";
import { GitHubApiDetails } from "./api-client";
import { GitHubFeatureFlags } from "./feature-flags";
import {
getRecordingLogger,
LoggedMessage,
setupActionsVars,
setupTests,
} from "./testing-utils";
import * as util from "./util";
import {
GitHubVariant,
HTTPError,
initializeEnvironment,
Mode,
withTmpDir,
} from "./util";

setupTests(test);

test.beforeEach(() => {
initializeEnvironment(Mode.actions, "1.2.3");

sinon
.stub(util, "getRequiredEnvParam")
.withArgs("GITHUB_REPOSITORY")
.returns("github/example");
});

const testApiDetails: GitHubApiDetails = {
auth: "1234",
url: "https://github.com",
};

function mockHttpRequests(
responseStatusCode: number,
flags: { [flagName: string]: boolean }
) {
// Passing an auth token is required, so we just use a dummy value
const client = github.getOctokit("123");

const requestSpy = sinon.stub(client, "request");

const optInSpy = requestSpy.withArgs(
"GET /repos/:owner/:repo/code-scanning/codeql-action/features"
);
if (responseStatusCode < 300) {
optInSpy.resolves({
status: responseStatusCode,
data: flags,
headers: {},
url: "GET /repos/:owner/:repo/code-scanning/codeql-action/features",
});
} else {
optInSpy.throws(new HTTPError("some error message", responseStatusCode));
}

sinon.stub(apiClient, "getApiClient").value(() => client);
}

const ALL_FEATURE_FLAGS_DISABLED_VARIANTS: Array<{
description: string;
gitHubVersion: util.GitHubVersion;
}> = [
{
description: "GHES",
gitHubVersion: { type: GitHubVariant.GHES, version: "3.0.0" },
},
{ description: "GHAE", gitHubVersion: { type: GitHubVariant.GHAE } },
];

for (const variant of ALL_FEATURE_FLAGS_DISABLED_VARIANTS) {
test(`All feature flags are disabled if running against ${variant.description}`, async (t) => {
await withTmpDir(async (tmpDir) => {
setupActionsVars(tmpDir, tmpDir);

const loggedMessages = [];
const featureFlags = new GitHubFeatureFlags(
variant.gitHubVersion,
testApiDetails,
getRecordingLogger(loggedMessages)
);

t.assert((await featureFlags.getDatabaseUploadsEnabled()) === false);
t.assert((await featureFlags.getMlPoweredQueriesEnabled()) === false);
t.assert((await featureFlags.getUploadsDomainEnabled()) === false);

t.assert(
loggedMessages.find(
(v: LoggedMessage) =>
v.type === "debug" &&
v.message ===
"Not running against github.com. Disabling all feature flags."
) !== undefined
);
});
});
}

test("Feature flags are disabled if they're not returned in API response", async (t) => {
await withTmpDir(async (tmpDir) => {
setupActionsVars(tmpDir, tmpDir);

const loggedMessages = [];
const featureFlags = new GitHubFeatureFlags(
{ type: GitHubVariant.DOTCOM },
testApiDetails,
getRecordingLogger(loggedMessages)
);

mockHttpRequests(200, {});

t.assert((await featureFlags.getDatabaseUploadsEnabled()) === false);
t.assert((await featureFlags.getMlPoweredQueriesEnabled()) === false);
t.assert((await featureFlags.getUploadsDomainEnabled()) === false);

for (const featureFlag of [
"database_uploads_enabled",
"ml_powered_queries_enabled",
"uploads_domain_enabled",
]) {
t.assert(
loggedMessages.find(
(v: LoggedMessage) =>
v.type === "debug" &&
v.message ===
`Feature flag '${featureFlag}' undefined in API response, considering it disabled.`
) !== undefined
);
}
});
});

test("All feature flags are disabled if the API request errors", async (t) => {
await withTmpDir(async (tmpDir) => {
setupActionsVars(tmpDir, tmpDir);

const loggedMessages = [];
const featureFlags = new GitHubFeatureFlags(
{ type: GitHubVariant.DOTCOM },
testApiDetails,
getRecordingLogger(loggedMessages)
);

mockHttpRequests(500, {});

t.assert((await featureFlags.getDatabaseUploadsEnabled()) === false);
t.assert((await featureFlags.getMlPoweredQueriesEnabled()) === false);
t.assert((await featureFlags.getUploadsDomainEnabled()) === false);

t.assert(
loggedMessages.find(
(v: LoggedMessage) =>
v.type === "info" &&
v.message ===
"Disabling all feature flags due to unknown error: Error: some error message"
) !== undefined
);
});
});

const FEATURE_FLAGS = [
"database_uploads_enabled",
"ml_powered_queries_enabled",
"uploads_domain_enabled",
];

for (const featureFlag of FEATURE_FLAGS) {
test(`Feature flag '${featureFlag}' is enabled if enabled in the API response`, async (t) => {
await withTmpDir(async (tmpDir) => {
setupActionsVars(tmpDir, tmpDir);

const loggedMessages = [];
const featureFlags = new GitHubFeatureFlags(
{ type: GitHubVariant.DOTCOM },
testApiDetails,
getRecordingLogger(loggedMessages)
);

const expectedFeatureFlags = {};
for (const f of FEATURE_FLAGS) {
expectedFeatureFlags[f] = false;
}
expectedFeatureFlags[featureFlag] = true;
mockHttpRequests(200, expectedFeatureFlags);

const actualFeatureFlags = {
database_uploads_enabled:
await featureFlags.getDatabaseUploadsEnabled(),
ml_powered_queries_enabled:
await featureFlags.getMlPoweredQueriesEnabled(),
uploads_domain_enabled: await featureFlags.getUploadsDomainEnabled(),
};

t.deepEqual(actualFeatureFlags, expectedFeatureFlags);
});
});
}
90 changes: 90 additions & 0 deletions src/feature-flags.ts
@@ -0,0 +1,90 @@
import { getApiClient, GitHubApiDetails } from "./api-client";
import { Logger } from "./logging";
import { parseRepositoryNwo } from "./repository";
import * as util from "./util";

interface FeatureFlags {
getDatabaseUploadsEnabled(): Promise<boolean>;
getMlPoweredQueriesEnabled(): Promise<boolean>;
getUploadsDomainEnabled(): Promise<boolean>;
}

/**
* A response from the GitHub API that contains feature flag enablement information for the CodeQL
* Action.
*
* It maps feature flags to whether they are enabled or not.
*/
type FeatureFlagsApiResponse = { [flagName: string]: boolean };

export class GitHubFeatureFlags implements FeatureFlags {
private cachedApiResponse: FeatureFlagsApiResponse | undefined;

constructor(
private gitHubVersion: util.GitHubVersion,
private apiDetails: GitHubApiDetails,
private logger: Logger
) {}

getDatabaseUploadsEnabled(): Promise<boolean> {
return this.getFeatureFlag("database_uploads_enabled");
}

getMlPoweredQueriesEnabled(): Promise<boolean> {
return this.getFeatureFlag("ml_powered_queries_enabled");
}

getUploadsDomainEnabled(): Promise<boolean> {
return this.getFeatureFlag("uploads_domain_enabled");
}

async preloadFeatureFlags(): Promise<void> {
await this.getApiResponse();
}

private async getFeatureFlag(name: string): Promise<boolean> {
const response = (await this.getApiResponse())[name];
if (response === undefined) {
this.logger.debug(
`Feature flag '${name}' undefined in API response, considering it disabled.`
);
}
return response || false;
}

private async getApiResponse(): Promise<FeatureFlagsApiResponse> {
const loadApiResponse = async () => {
// Do nothing when not running against github.com
if (this.gitHubVersion.type !== util.GitHubVariant.DOTCOM) {
this.logger.debug(
"Not running against github.com. Disabling all feature flags."
);
return {};
}
const client = getApiClient(this.apiDetails);
const repositoryNwo = parseRepositoryNwo(
util.getRequiredEnvParam("GITHUB_REPOSITORY")
);
try {
const response = await client.request(
"GET /repos/:owner/:repo/code-scanning/codeql-action/features",
{
owner: repositoryNwo.owner,
repo: repositoryNwo.repo,
}
);
return response.data;
} catch (e) {
console.log(e);
this.logger.info(
`Disabling all feature flags due to unknown error: ${e}`
);
return {};
}
};

const apiResponse = this.cachedApiResponse || (await loadApiResponse());
this.cachedApiResponse = apiResponse;
return apiResponse;
}
}
30 changes: 30 additions & 0 deletions src/testing-utils.ts
@@ -2,6 +2,7 @@ import { TestInterface } from "ava";
import * as sinon from "sinon";

import * as CodeQL from "./codeql";
import { Logger } from "./logging";

type TestContext = {
stdoutWrite: any;
@@ -89,3 +90,32 @@ export function setupActionsVars(tempDir: string, toolsDir: string) {
process.env["RUNNER_TEMP"] = tempDir;
process.env["RUNNER_TOOL_CACHE"] = toolsDir;
}

export interface LoggedMessage {
type: "debug" | "info" | "warning" | "error";
message: string | Error;
}

export function getRecordingLogger(messages: LoggedMessage[]): Logger {
return {
debug: (message: string) => {
messages.push({ type: "debug", message });
console.debug(message);
},
info: (message: string) => {
messages.push({ type: "info", message });
console.info(message);
},
warning: (message: string | Error) => {
messages.push({ type: "warning", message });
console.warn(message);
},
error: (message: string | Error) => {
messages.push({ type: "error", message });
console.error(message);
},
isDebug: () => true,
startGroup: () => undefined,
endGroup: () => undefined,
};
}

0 comments on commit 04671ef

Please sign in to comment.