diff --git a/src/upload-lib.test.ts b/src/upload-lib.test.ts index a1fdf218e..640338f08 100644 --- a/src/upload-lib.test.ts +++ b/src/upload-lib.test.ts @@ -1,13 +1,26 @@ import * as fs from "fs"; import * as path from "path"; +import zlib from "zlib"; import test from "ava"; +import * as sinon from "sinon"; +import * as actionsUtil from "./actions-util"; import { AnalysisKind, CodeQuality, CodeScanning } from "./analyses"; +import * as api from "./api-client"; +import * as codeqlModule from "./codeql"; +import * as configUtils from "./config-utils"; +import * as fingerprints from "./fingerprints"; +import * as gitUtils from "./git-utils"; import { getRunnerLogger, Logger } from "./logging"; -import { setupTests } from "./testing-utils"; +import { createFeatures, setupTests } from "./testing-utils"; import * as uploadLib from "./upload-lib"; -import { GitHubVariant, initializeEnvironment, withTmpDir } from "./util"; +import { + GitHubVariant, + initializeEnvironment, + SarifFile, + withTmpDir, +} from "./util"; setupTests(test); @@ -851,19 +864,433 @@ test("shouldConsiderInvalidRequest returns correct recognises processing errors" t.false(uploadLib.shouldConsiderInvalidRequest(error3)); }); -function createMockSarif(id?: string, tool?: string) { - return { - runs: [ - { - automationDetails: { - id, - }, - tool: { - driver: { - name: tool, - }, - }, - }, - ], +// Helper function to set up common environment variables for upload tests +function setupUploadEnvironment( + tmpDir: string, + extraVars?: Record, +) { + const originalEnv: Record = {}; + + // Define all environment variables we might set + const envVars = { + RUNNER_TEMP: tmpDir, + GITHUB_EVENT_NAME: "push", + GITHUB_WORKFLOW: "test-workflow", + GITHUB_REPOSITORY: "owner/repo", + GITHUB_RUN_ID: "123", + GITHUB_RUN_ATTEMPT: "1", + ...extraVars, + }; + + // Save original values and set new ones + for (const [key, value] of Object.entries(envVars)) { + originalEnv[key] = process.env[key]; + process.env[key] = value; + } + + // Return a cleanup function that restores the original environment + return () => { + for (const [key, originalValue] of Object.entries(originalEnv)) { + if (originalValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalValue; + } + } + }; +} + +// Helper function to stub common external dependencies for upload tests +function stubUploadDependencies() { + sinon.stub(api, "getGitHubVersion").resolves({ + type: GitHubVariant.DOTCOM, + }); + sinon.stub(api, "getAnalysisKey").resolves("test-key"); + sinon.stub(actionsUtil, "getRequiredInput").returns("{}"); + const addFingerprintsStub = sinon + .stub(fingerprints, "addFingerprints") + .resolvesArg(0); + sinon.stub(gitUtils, "getCommitOid").resolves("abc123"); + sinon.stub(gitUtils, "getRef").resolves("refs/heads/main"); + sinon.stub(gitUtils, "determineBaseBranchHeadCommitOid").resolves(undefined); + + return { addFingerprintsStub }; +} + +test("uploadSpecifiedFiles - single SARIF file", async (t) => { + await withTmpDir(async (tmpDir) => { + const logger = getRunnerLogger(true); + const features = createFeatures([]); + const sarifPath = path.join(tmpDir, "test.sarif"); + const checkoutPath = tmpDir; + + // Create a valid SARIF file + const mockSarif = createMockSarif("test-id", "TestTool"); + fs.writeFileSync(sarifPath, JSON.stringify(mockSarif)); + + const cleanupEnv = setupUploadEnvironment(tmpDir, { + CODEQL_ACTION_SKIP_SARIF_UPLOAD: "true", + }); + const { addFingerprintsStub } = stubUploadDependencies(); + + try { + const result = await uploadLib.uploadSpecifiedFiles( + [sarifPath], + checkoutPath, + "test-category", + features, + logger, + CodeScanning, + ); + + // Verify the result + t.is(result.sarifID, "dummy-sarif-id"); + t.truthy(result.statusReport.raw_upload_size_bytes); + t.truthy(result.statusReport.zipped_upload_size_bytes); + t.is(result.statusReport.num_results_in_sarif, 0); + + // Verify external dependencies were called + t.true(addFingerprintsStub.calledOnce); + } finally { + sinon.restore(); + cleanupEnv(); + } + }); +}); + +test("uploadSpecifiedFiles - multiple SARIF files", async (t) => { + await withTmpDir(async (tmpDir) => { + const logger = getRunnerLogger(true); + const features = createFeatures([]); + const sarifPath1 = path.join(tmpDir, "test1.sarif"); + const sarifPath2 = path.join(tmpDir, "test2.sarif"); + const checkoutPath = tmpDir; + + // Create valid SARIF files with CodeQL as the tool name to trigger CLI merge + const mockSarif1 = createMockSarif("test-id-1", "CodeQL"); + const mockSarif2 = createMockSarif("test-id-2", "CodeQL"); + fs.writeFileSync(sarifPath1, JSON.stringify(mockSarif1)); + fs.writeFileSync(sarifPath2, JSON.stringify(mockSarif2)); + + // Set up environment WITHOUT skip flags to perform actual upload + const cleanupEnv = setupUploadEnvironment(tmpDir); + stubUploadDependencies(); + + // Create a mock combined SARIF that will be "returned" by mergeResults + const mockCombinedSarif = createMockSarif("combined-id", "CombinedTool"); + + // Mock CodeQL CLI with a spy to verify inputs + const mergeResultsStub = sinon + .stub() + .callsFake(async (_inputs: string[], output: string) => { + // Write the mock combined SARIF to the output file + fs.writeFileSync(output, JSON.stringify(mockCombinedSarif)); + }); + + sinon.stub(codeqlModule, "getCodeQL").resolves({ + supportsFeature: async () => true, + mergeResults: mergeResultsStub, + } as unknown as codeqlModule.CodeQL); + + // Mock getConfig to return a config with our mocked CodeQL + sinon.stub(configUtils, "getConfig").resolves({ + codeQLCmd: "/fake/codeql", + tempDir: tmpDir, + } as unknown as configUtils.Config); + + // Mock the API client to capture the upload request + const mockApiClient = { + request: sinon.stub().resolves({ + status: 200, + data: { id: "combined-sarif-id-456" }, + }), + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + sinon.stub(api, "getApiClient").returns(mockApiClient as any); + + try { + const result = await uploadLib.uploadSpecifiedFiles( + [sarifPath1, sarifPath2], + checkoutPath, + "test-category", + features, + logger, + CodeScanning, + ); + + // Verify the result uses the uploaded ID + t.is(result.sarifID, "combined-sarif-id-456"); + t.truthy(result.statusReport.raw_upload_size_bytes); + + // Verify the API was called + t.true(mockApiClient.request.calledOnce); + + // Verify the uploaded payload contains the combined SARIF from our mock + const uploadCall = mockApiClient.request.getCall(0); + const uploadPayload = uploadCall.args[1]; + + // Decode and verify the uploaded SARIF matches what our mock produced + const uploadedSarifBase64 = uploadPayload.data.sarif as string; + const uploadedSarifGzipped = Buffer.from(uploadedSarifBase64, "base64"); + const uploadedSarifJson = zlib + .gunzipSync(uploadedSarifGzipped) + .toString(); + const uploadedSarif = JSON.parse(uploadedSarifJson) as SarifFile; + + // Verify it contains the combined SARIF data from our CodeQL mock + t.is(uploadedSarif.runs[0].automationDetails?.id, "combined-id"); + t.is(uploadedSarif.runs[0].tool?.driver?.name, "CombinedTool"); + + // Verify mergeResults was called with the correct input files + t.true(mergeResultsStub.calledOnce); + const mergeCall = mergeResultsStub.getCall(0); + const inputPaths = mergeCall.args[0]; + t.deepEqual(inputPaths, [sarifPath1, sarifPath2]); + } finally { + sinon.restore(); + cleanupEnv(); + } + }); +}); + +test("uploadSpecifiedFiles - category is fixed by upload target on quality sarif", async (t) => { + await withTmpDir(async (tmpDir) => { + const logger = getRunnerLogger(true); + const features = createFeatures([]); + const sarifPath = path.join(tmpDir, "test.quality.sarif"); + const checkoutPath = tmpDir; + + // Create a SARIF without automationDetails so the category will be applied + const mockSarif = createMockSarif(undefined, "QualityTool"); + fs.writeFileSync(sarifPath, JSON.stringify(mockSarif)); + + // Set up environment WITHOUT skip flags to perform actual upload + // Set GITHUB_EVENT_NAME to "dynamic" to enable isDefaultSetup() check + const cleanupEnv = setupUploadEnvironment(tmpDir, { + GITHUB_EVENT_NAME: "dynamic", + }); + stubUploadDependencies(); + + // Mock the API client to capture the upload request + const mockApiClient = { + request: sinon.stub().resolves({ + status: 200, + data: { id: "quality-sarif-id-789" }, + }), + }; + sinon.stub(api, "getApiClient").returns( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + mockApiClient as any, + ); + + try { + const result = await uploadLib.uploadSpecifiedFiles( + [sarifPath], + checkoutPath, + "/language:c#", + features, + logger, + CodeQuality, + ); + + // Verify actual upload happened + t.is(result.sarifID, "quality-sarif-id-789"); + t.true(mockApiClient.request.calledOnce); + + // Verify the category was fixed from /language:c# to /language:csharp + const uploadCall = mockApiClient.request.getCall(0); + const uploadPayload = uploadCall.args[1]; + + // Decode and verify the uploaded SARIF contains the fixed category + const uploadedSarifBase64 = uploadPayload.data.sarif as string; + const uploadedSarifGzipped = Buffer.from(uploadedSarifBase64, "base64"); + const uploadedSarifJson = zlib + .gunzipSync(uploadedSarifGzipped) + .toString(); + const uploadedSarif = JSON.parse(uploadedSarifJson) as SarifFile; + + // The automation details id should have been updated to use csharp instead of c# + t.is(uploadedSarif.runs[0].automationDetails?.id, "/language:csharp/"); + } finally { + sinon.restore(); + cleanupEnv(); + } + }); +}); + +test("uploadSpecifiedFiles - dumps SARIF when SARIF_DUMP_DIR is set", async (t) => { + await withTmpDir(async (tmpDir) => { + const logger = getRunnerLogger(true); + const features = createFeatures([]); + const sarifPath = path.join(tmpDir, "test.sarif"); + const dumpDir = path.join(tmpDir, "dump"); + const checkoutPath = tmpDir; + + fs.mkdirSync(dumpDir); + const mockSarif = createMockSarif("test-id", "TestTool"); + fs.writeFileSync(sarifPath, JSON.stringify(mockSarif)); + + const cleanupEnv = setupUploadEnvironment(tmpDir, { + CODEQL_ACTION_SARIF_DUMP_DIR: dumpDir, + CODEQL_ACTION_SKIP_SARIF_UPLOAD: "true", + }); + stubUploadDependencies(); + + try { + await uploadLib.uploadSpecifiedFiles( + [sarifPath], + checkoutPath, + "test-category", + features, + logger, + CodeScanning, + ); + + // Verify SARIF was dumped + const dumpedFile = path.join( + dumpDir, + `upload${CodeScanning.sarifExtension}`, + ); + t.true(fs.existsSync(dumpedFile)); + } finally { + sinon.restore(); + cleanupEnv(); + } + }); +}); + +test("uploadSpecifiedFiles - performs actual upload when skip flags are not set", async (t) => { + await withTmpDir(async (tmpDir) => { + const logger = getRunnerLogger(true); + const features = createFeatures([]); + const sarifPath = path.join(tmpDir, "test.sarif"); + const checkoutPath = tmpDir; + + const mockSarif = createMockSarif("test-id", "TestTool"); + fs.writeFileSync(sarifPath, JSON.stringify(mockSarif)); + + // Set up environment WITHOUT skip flags + const cleanupEnv = setupUploadEnvironment(tmpDir); + stubUploadDependencies(); + + // Mock the API client to capture the upload request + const mockApiClient = { + request: sinon.stub().resolves({ + status: 200, + data: { id: "real-sarif-id-123" }, + }), + }; + sinon.stub(api, "getApiClient").returns( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + mockApiClient as any, + ); + + try { + const result = await uploadLib.uploadSpecifiedFiles( + [sarifPath], + checkoutPath, + "test-category", + features, + logger, + CodeScanning, + ); + + // Verify actual upload happened + t.is(result.sarifID, "real-sarif-id-123"); + t.true(mockApiClient.request.calledOnce); + + // Verify the upload target was correct + const uploadCall = mockApiClient.request.getCall(0); + t.is(uploadCall.args[0], CodeScanning.target); + + // Verify payload structure + const uploadPayload = uploadCall.args[1]; + t.truthy(uploadPayload.data.sarif); + t.is(uploadPayload.data.commit_oid, "abc123"); + t.is(uploadPayload.data.ref, "refs/heads/main"); + } finally { + sinon.restore(); + cleanupEnv(); + } + }); +}); + +test("uploadSpecifiedFiles - skips upload when CODEQL_ACTION_TEST_MODE is set", async (t) => { + await withTmpDir(async (tmpDir) => { + const logger = getRunnerLogger(true); + const features = createFeatures([]); + const sarifPath = path.join(tmpDir, "test.sarif"); + const checkoutPath = tmpDir; + + const mockSarif = createMockSarif("test-id", "TestTool"); + fs.writeFileSync(sarifPath, JSON.stringify(mockSarif)); + + // Set up environment with TEST_MODE instead of SKIP_SARIF_UPLOAD + const cleanupEnv = setupUploadEnvironment(tmpDir, { + CODEQL_ACTION_TEST_MODE: "true", + }); + stubUploadDependencies(); + + // Mock the API client - this should NOT be called + const mockApiClient = { + request: sinon.stub().resolves({ + status: 200, + data: { id: "should-not-be-used" }, + }), + }; + sinon.stub(api, "getApiClient").returns( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + mockApiClient as any, + ); + + try { + const result = await uploadLib.uploadSpecifiedFiles( + [sarifPath], + checkoutPath, + "test-category", + features, + logger, + CodeScanning, + ); + + // Verify upload was skipped + t.is(result.sarifID, "dummy-sarif-id"); + t.false( + mockApiClient.request.called, + "API request should not be called when in test mode", + ); + + // Verify payload was saved to file instead + const payloadFile = path.join(tmpDir, "payload.json"); + t.true(fs.existsSync(payloadFile)); + + const savedPayload = JSON.parse(fs.readFileSync(payloadFile, "utf8")); + t.truthy(savedPayload.sarif); + t.is(savedPayload.commit_oid, "abc123"); + } finally { + sinon.restore(); + cleanupEnv(); + } + }); +}); + +function createMockSarif(id?: string, tool?: string) { + const run: any = { + tool: { + driver: { + name: tool, + }, + }, + results: [], + }; + + // Only include automationDetails if id is provided + if (id !== undefined) { + run.automationDetails = { id }; + } + + return { + version: "2.1.0", + runs: [run], }; }