diff --git a/packages/core/src/api-client.test.ts b/packages/core/src/api-client.test.ts index 6dbe713..45747cb 100644 --- a/packages/core/src/api-client.test.ts +++ b/packages/core/src/api-client.test.ts @@ -1,55 +1,149 @@ import { setupJest } from "../mocks/server"; -import { ArgosApiClient, createArgosApiClient } from "./api-client"; +import { + ArgosApiClient, + createArgosApiClient, + getBearerToken, +} from "./api-client"; setupJest(); let apiClient: ArgosApiClient; -beforeAll(() => { - apiClient = createArgosApiClient({ - baseUrl: "https://api.argos-ci.dev", - token: "92d832e0d22ab113c8979d73a87a11130eaa24a9", + +describe("#createArgosApiClient", () => { + beforeAll(() => { + apiClient = createArgosApiClient({ + baseUrl: "https://api.argos-ci.dev", + bearerToken: "Bearer 92d832e0d22ab113c8979d73a87a11130eaa24a9", + }); }); -}); -describe("#createBuild", () => { - it("creates build", async () => { - const result = await apiClient.createBuild({ - commit: "f16f980bd17cccfa93a1ae7766727e67950773d0", - screenshotKeys: ["123", "456"], - }); - expect(result).toEqual({ - build: { - id: "123", - url: "https://app.argos-ci.dev/builds/123", - }, - screenshots: [ - { - key: "123", - putUrl: "https://api.s3.dev/upload/123", + describe("#createBuild", () => { + it("creates build", async () => { + const result = await apiClient.createBuild({ + commit: "f16f980bd17cccfa93a1ae7766727e67950773d0", + screenshotKeys: ["123", "456"], + }); + expect(result).toEqual({ + build: { + id: "123", + url: "https://app.argos-ci.dev/builds/123", }, - { - key: "456", - putUrl: "https://api.s3.dev/upload/456", + screenshots: [ + { + key: "123", + putUrl: "https://api.s3.dev/upload/123", + }, + { + key: "456", + putUrl: "https://api.s3.dev/upload/456", + }, + ], + }); + }); + }); + + describe("#updateBuild", () => { + it("updates build", async () => { + const result = await apiClient.updateBuild({ + buildId: "123", + screenshots: [ + { key: "123", name: "screenshot 1" }, + { key: "456", name: "screenshot 2" }, + ], + }); + expect(result).toEqual({ + build: { + id: "123", + url: "https://app.argos-ci.dev/builds/123", }, - ], + }); }); }); }); -describe("#updateBuild", () => { - it("updates build", async () => { - const result = await apiClient.updateBuild({ - buildId: "123", - screenshots: [ - { key: "123", name: "screenshot 1" }, - { key: "456", name: "screenshot 2" }, - ], - }); - expect(result).toEqual({ - build: { - id: "123", - url: "https://app.argos-ci.dev/builds/123", - }, +describe("#getBearerToken", () => { + describe("without CI", () => { + describe("without token", () => { + it("should throw", () => { + const config = {}; + expect(() => getBearerToken(config)).toThrow( + "Missing Argos repository token 'ARGOS_TOKEN'" + ); + }); + }); + + describe("with token", () => { + it("should return bearer token", () => { + const config = { token: "this-token" }; + expect(getBearerToken(config)).toBe(`Bearer this-token`); + }); + }); + }); + + describe("with unknown CI", () => { + const configProps = { ciService: "unknownCI" }; + + describe("without token", () => { + it("should throw", () => { + const config = { ...configProps }; + expect(() => getBearerToken(config)).toThrow( + "Missing Argos repository token 'ARGOS_TOKEN'" + ); + }); + }); + + describe("with token", () => { + it("should return bearer token", () => { + const config = { ...configProps, token: "this-token" }; + expect(getBearerToken(config)).toBe(`Bearer this-token`); + }); + }); + }); + + describe("with Github Actions CI", () => { + const configProps = { ciService: "GitHub Actions" }; + + describe("with token", () => { + it("should return bearer token", () => { + const config = { ...configProps, token: "this-token" }; + expect(getBearerToken(config)).toBe(`Bearer this-token`); + }); + }); + + describe("without token but with CI env variables", () => { + it("should return a composite token", () => { + const config = { + ...configProps, + owner: "this-owner", + repository: "this-repository", + jobId: "this-jobId", + }; + + const base64 = Buffer.from( + JSON.stringify({ + owner: config.owner, + repository: config.repository, + jobId: config.jobId, + }), + "utf8" + ).toString("base64"); + + const bearerToken = getBearerToken(config); + + expect(bearerToken).toBe(`Bearer tokenless-github-${base64}`); + expect(bearerToken).toBe( + "Bearer tokenless-github-eyJvd25lciI6InRoaXMtb3duZXIiLCJyZXBvc2l0b3J5IjoidGhpcy1yZXBvc2l0b3J5Iiwiam9iSWQiOiJ0aGlzLWpvYklkIn0=" + ); + }); + }); + + describe("without token and without CI env variables", () => { + it("should throw", () => { + const config = { ...configProps }; + expect(() => getBearerToken(config)).toThrow( + "Automatic GitHub Actions variables detection failed. Please add the 'ARGOS_TOKEN'" + ); + }); }); }); }); diff --git a/packages/core/src/api-client.ts b/packages/core/src/api-client.ts index 1b54bbf..f344260 100644 --- a/packages/core/src/api-client.ts +++ b/packages/core/src/api-client.ts @@ -2,7 +2,7 @@ import axios from "axios"; export interface ApiClientOptions { baseUrl: string; - token: string; + bearerToken: string; } export interface CreateBuildInput { @@ -44,13 +44,51 @@ export interface ArgosApiClient { updateBuild: (input: UpdateBuildInput) => Promise; } +const base64Encode = (obj: any) => + Buffer.from(JSON.stringify(obj), "utf8").toString("base64"); + +export const getBearerToken = ({ + token, + ciService, + owner, + repository, + jobId, +}: { + token?: string | null; + ciService?: string | null; + owner?: string | null; + repository?: string | null; + jobId?: string | null; +}) => { + if (token) return `Bearer ${token}`; + + switch (ciService) { + case "GitHub Actions": { + if (!owner || !repository || !jobId) { + throw new Error( + `Automatic ${ciService} variables detection failed. Please add the 'ARGOS_TOKEN'` + ); + } + + return `Bearer tokenless-github-${base64Encode({ + owner, + repository, + jobId, + })}`; + } + + default: + throw new Error("Missing Argos repository token 'ARGOS_TOKEN'"); + } +}; + export const createArgosApiClient = ( options: ApiClientOptions ): ArgosApiClient => { const axiosInstance = axios.create({ baseURL: options.baseUrl, headers: { - Authorization: `Bearer ${options.token}`, + Authorization: options.bearerToken, "Content-Type": "application/json", Accept: "application/json", }, diff --git a/packages/core/src/ci-environment/index.ts b/packages/core/src/ci-environment/index.ts index 2fc2156..decbc25 100644 --- a/packages/core/src/ci-environment/index.ts +++ b/packages/core/src/ci-environment/index.ts @@ -27,6 +27,10 @@ export const getCiEnvironment = ({ : null; const commit = ciContext.commit ?? null; const branch = (ciContext.branch || ciContext.prBranch) ?? null; + const slug = ciContext.slug ? ciContext.slug.split("/") : null; + const owner = slug ? slug[0] : null; + const repository = slug ? slug[1] : null; + const jobId = ciContext.job ?? null; - return commit ? { name, commit, branch } : null; + return commit ? { name, commit, branch, owner, repository, jobId } : null; }; diff --git a/packages/core/src/ci-environment/services/github-actions.ts b/packages/core/src/ci-environment/services/github-actions.ts index d6ce14c..ee90a8f 100644 --- a/packages/core/src/ci-environment/services/github-actions.ts +++ b/packages/core/src/ci-environment/services/github-actions.ts @@ -62,9 +62,12 @@ function getBranch({ env }: Context) { const service: Service = { detect: ({ env }) => Boolean(env.GITHUB_ACTIONS), config: ({ env }) => ({ - name: "GiHub Actions", + name: "GitHub Actions", commit: getSha({ env }), branch: getBranch({ env }), + owner: env.GITHUB_REPOSITORY_OWNER || null, + repository: env.GITHUB_REPOSITORY || null, + jobId: env.GITHUB_JOB || null, }), }; diff --git a/packages/core/src/ci-environment/services/heroku.ts b/packages/core/src/ci-environment/services/heroku.ts index c61d9c4..b77e1fa 100644 --- a/packages/core/src/ci-environment/services/heroku.ts +++ b/packages/core/src/ci-environment/services/heroku.ts @@ -6,6 +6,9 @@ const service: Service = { name: "Heroku", commit: env.HEROKU_TEST_RUN_COMMIT_VERSION || null, branch: env.HEROKU_TEST_RUN_BRANCH || null, + owner: null, + repository: null, + jobId: env.HEROKU_TEST_RUN_ID || null, }), }; diff --git a/packages/core/src/ci-environment/types.ts b/packages/core/src/ci-environment/types.ts index a7e0309..03ed10f 100644 --- a/packages/core/src/ci-environment/types.ts +++ b/packages/core/src/ci-environment/types.ts @@ -10,6 +10,9 @@ export interface CiEnvironment { name: string | null; commit: string | null; branch: string | null; + owner: string | null; + repository: string | null; + jobId: string | null; } export interface Service { diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index ee31d98..1d961fc 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -3,26 +3,24 @@ import { createConfig } from "./config"; describe("#createConfig", () => { it("gets config", () => { const config = createConfig(); - config.load({ - commit: "f16f980bd17cccfa93a1ae7766727e67950773d0", - token: "92d832e0d22ab113c8979d73a87a11130eaa24a9", - }); + config.load({ commit: "f16f980bd17cccfa93a1ae7766727e67950773d0" }); expect(config.get()).toEqual({ apiBaseUrl: "https://api.argos-ci.com/v2/", commit: "f16f980bd17cccfa93a1ae7766727e67950773d0", branch: null, - token: "92d832e0d22ab113c8979d73a87a11130eaa24a9", + token: null, buildName: null, parallel: false, parallelNonce: null, parallelTotal: null, ciService: null, + jobId: null, + repository: null, + owner: null, }); }); it("throws with invalid commit", () => { - expect(() => createConfig().validate()).toThrow( - "commit: Invalid commit\ntoken: Must be a valid Argos repository token" - ); + expect(() => createConfig().validate()).toThrow("commit: Invalid commit"); }); }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 8d8d3f0..734a6c2 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -22,7 +22,7 @@ const mustBeCommit = (value: any) => { }; const mustBeArgosToken = (value: any) => { - if (value.length !== 40) { + if (value && value.length !== 40) { throw new Error("Must be a valid Argos repository token"); } }; @@ -46,7 +46,7 @@ const schema = { }, token: { env: "ARGOS_TOKEN", - default: "", + default: null, format: mustBeArgosToken, }, buildName: { @@ -77,17 +77,35 @@ const schema = { default: null, nullable: true, }, + jobId: { + format: String, + default: null, + nullable: true, + }, + owner: { + format: String, + default: null, + nullable: true, + }, + repository: { + format: String, + default: null, + nullable: true, + }, }; export interface Config { apiBaseUrl: string; commit: string; branch: string | null; - token: string; + token: string | null; buildName: string | null; parallel: boolean; parallelNonce: string | null; parallelTotal: number | null; + owner: string | null; + repository: string | null; + jobId: string | null; } export const createConfig = () => { diff --git a/packages/core/src/upload.ts b/packages/core/src/upload.ts index 3b85cc3..9819938 100644 --- a/packages/core/src/upload.ts +++ b/packages/core/src/upload.ts @@ -4,7 +4,7 @@ import { getCiEnvironment } from "./ci-environment"; import { discoverScreenshots } from "./discovery"; import { optimizeScreenshot, getImageFormat } from "./optimize"; import { hashFile } from "./hashing"; -import { createArgosApiClient } from "./api-client"; +import { createArgosApiClient, getBearerToken } from "./api-client"; import { upload as uploadToS3 } from "./s3"; import { debug } from "./debug"; @@ -61,6 +61,9 @@ const getConfigFromOptions = (options: UploadParameters) => { commit: ciEnv.commit, branch: ciEnv.branch, ciService: ciEnv.name, + owner: ciEnv.owner, + repository: ciEnv.repository, + jobId: ciEnv.jobId, }) ); } @@ -79,6 +82,11 @@ export const upload = async (params: UploadParameters) => { const config = getConfigFromOptions(params); const files = params.files ?? ["**/*.{png,jpg,jpeg}"]; + const apiClient = createArgosApiClient({ + baseUrl: config.apiBaseUrl, + bearerToken: getBearerToken(config), + }); + // Collect screenshots const foundScreenshots = await discoverScreenshots(files, { root: params.root, @@ -95,11 +103,6 @@ export const upload = async (params: UploadParameters) => { }) ); - const apiClient = createArgosApiClient({ - baseUrl: config.apiBaseUrl, - token: config.token, - }); - // Create build debug("Creating build"); const result = await apiClient.createBuild({