diff --git a/.envrc.example b/.envrc.example index ccbd5e8c..eb643a77 100644 --- a/.envrc.example +++ b/.envrc.example @@ -1,19 +1,41 @@ #!/usr/bin/env bash -# API Configuration +# API Configuration (override defaults in src/config/constants.ts) export GRAPHQL_HOST='https://api.nes.herodevs.com'; export GRAPHQL_PATH='/graphql'; export EOL_REPORT_URL='https://eol-report-card.apps.herodevs.com/reports'; export ANALYTICS_URL='https://eol-api.herodevs.com/track'; +# OAuth (for hd auth login) export OAUTH_CONNECT_URL=''; export OAUTH_CLIENT_ID=''; +# export OAUTH_CALLBACK_PORT='4000'; +# export OAUTH_CALLBACK_REDIRECT='http://localhost:4000/oauth2/callback'; -# Performance tuning (optional) +# IAM (for CI token provisioning via hd auth provision-ci-token) +# export IAM_HOST='https://apps.herodevs.io/api/iam'; +# export IAM_PATH='/graphql'; + +# Auth toggles (optional; both default false) +# export ENABLE_AUTH='true'; +# export ENABLE_USER_SETUP='true'; + +# CI token (for headless flows) +# HD_ORG_ID: required when using HD_AUTH_TOKEN; also stored when provisioning +# HD_AUTH_TOKEN: refresh token from provision; exchanged for access token +# HD_ACCESS_TOKEN: direct access token (skips exchange) +# export HD_ORG_ID='1234'; +# export HD_AUTH_TOKEN=''; +# export HD_ACCESS_TOKEN=''; + +# Performance (optional) # export CONCURRENT_PAGE_REQUESTS='3'; # export PAGE_SIZE='500'; -# Keyring configuration (optional, for debugging) +# Privacy +# export TRACKING_OPT_OUT='true'; + +# Keyring (optional; for debugging token storage) # export HD_AUTH_SERVICE_NAME='@herodevs/cli'; # export HD_AUTH_ACCESS_KEY='access-token'; # export HD_AUTH_REFRESH_KEY='refresh-token'; diff --git a/README.md b/README.md index 6043b982..fdf394f6 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,11 @@ Maven and Gradle projects should run an install and build before scanning ## Usage ```sh-session -$ npm install -g @herodevs/cli@beta +$ npm install -g @herodevs/cli $ hd COMMAND running command... $ hd (--version) -@herodevs/cli/2.0.0-beta.14 darwin-arm64 node-v24.10.0 +@herodevs/cli/2.0.0-beta.14 darwin-arm64 node-v24.11.1 $ hd --help [COMMAND] USAGE $ hd COMMAND @@ -81,13 +81,57 @@ USAGE ## Commands +* [`hd auth login`](#hd-auth-login) +* [`hd auth logout`](#hd-auth-logout) +* [`hd auth provision-ci-token`](#hd-auth-provision-ci-token) * [`hd help [COMMAND]`](#hd-help-command) * [`hd report committers`](#hd-report-committers) * [`hd scan eol`](#hd-scan-eol) * [`hd tracker init`](#hd-tracker-init) * [`hd tracker run`](#hd-tracker-run) * [`hd update [CHANNEL]`](#hd-update-channel) - * **NOTE:** Only applies to [binary installation method](#binary-installation). NPM users should use [`npm install`](#global-npm-installation) to update to the latest version. + +## `hd auth login` + +OAuth CLI login + +``` +USAGE + $ hd auth login + +DESCRIPTION + OAuth CLI login +``` + +_See code: [src/commands/auth/login.ts](https://github.com/herodevs/cli/blob/v2.0.0-beta.14/src/commands/auth/login.ts)_ + +## `hd auth logout` + +Logs out of HeroDevs OAuth and clears stored tokens + +``` +USAGE + $ hd auth logout + +DESCRIPTION + Logs out of HeroDevs OAuth and clears stored tokens +``` + +_See code: [src/commands/auth/logout.ts](https://github.com/herodevs/cli/blob/v2.0.0-beta.14/src/commands/auth/logout.ts)_ + +## `hd auth provision-ci-token` + +Provision a CI/CD long-lived refresh token for headless auth + +``` +USAGE + $ hd auth provision-ci-token + +DESCRIPTION + Provision a CI/CD long-lived refresh token for headless auth +``` + +_See code: [src/commands/auth/provision-ci-token.ts](https://github.com/herodevs/cli/blob/v2.0.0-beta.14/src/commands/auth/provision-ci-token.ts)_ ## `hd help [COMMAND]` @@ -107,7 +151,7 @@ DESCRIPTION Display help for hd. ``` -_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.37/src/commands/help.ts)_ +_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.37/src/commands/help.ts)_ ## `hd report committers` @@ -121,10 +165,10 @@ USAGE FLAGS -c, --csv Output in CSV format -d, --directory= Directory to search - -e, --afterDate= [default: 2025-02-02] Start date (format: yyyy-MM-dd) + -e, --afterDate= [default: 2025-02-18] Start date (format: yyyy-MM-dd) -m, --months= [default: 12] The number of months of git history to review. Cannot be used along beforeDate and afterDate - -s, --beforeDate= [default: 2026-02-02] End date (format: yyyy-MM-dd) + -s, --beforeDate= [default: 2026-02-18] End date (format: yyyy-MM-dd) -s, --save Save the committers report as herodevs.committers. -x, --exclude=... Path Exclusions (eg -x="./src/bin" -x="./dist") --json Output to JSON format @@ -254,14 +298,12 @@ EXAMPLES $ hd tracker run -d tracker -f settings.json ``` -_See code: [src/commands/tracker/run.ts](https://github.com/herodevs/cli/blob/main/src/commands/tracker/run.ts)_ +_See code: [src/commands/tracker/run.ts](https://github.com/herodevs/cli/blob/v2.0.0-beta.14/src/commands/tracker/run.ts)_ ## `hd update [CHANNEL]` update the hd CLI -* **NOTE:** Only applies to [binary installation method](#binary-installation). NPM users should use [`npm install`](#global-npm-installation) to update to the latest version. - ``` USAGE $ hd update [CHANNEL] [--force | | [-a | -v | -i]] [-b ] @@ -294,13 +336,80 @@ EXAMPLES $ hd update --available ``` -_See code: [@oclif/plugin-update](https://github.com/oclif/plugin-update/blob/4.7.18/src/commands/update.ts)_ +_See code: [@oclif/plugin-update](https://github.com/oclif/plugin-update/blob/v4.7.18/src/commands/update.ts)_ ## CI/CD Usage You can use `@herodevs/cli` in your CI/CD pipelines to automate EOL scanning. +### CI/CD authentication + +For headless use in CI/CD (e.g. GitHub Actions, GitLab CI), the CLI supports long-lived organization-scoped refresh tokens. You do not need to run an interactive login in the pipeline. + +**One-time setup (interactive):** + +```bash +hd auth login +hd auth provision-ci-token +``` + +Copy the token output, add as CI secrets: `HD_AUTH_TOKEN` and `HD_ORG_ID` (orgId is obtained from user setup and stored at provision time when using locally). + +**CI pipeline (headless):** Run `hd scan eol` directly with `HD_AUTH_TOKEN` and `HD_ORG_ID` set. The CLI exchanges the token for an access token automatically: + +```bash +export HD_ORG_ID= HD_AUTH_TOKEN="" +hd scan eol --dir . +``` + +| Secret / Env Var | Purpose | +|------------------|---------| +| `HD_AUTH_TOKEN` | Long-lived refresh token from provision | +| `HD_ORG_ID` | Organization ID (required when using HD_AUTH_TOKEN; also stored at provision time when using local file) | + +#### Local testing + +Reproduce the CI flow locally: + +```bash +export HD_ORG_ID=1234 HD_AUTH_TOKEN="eyJ..." +hd scan eol --dir /path/to/project +``` + +#### GitHub Actions (authenticated scan) + +Add secrets `HD_AUTH_TOKEN` and `HD_ORG_ID` in your repository or organization, then: + +```yaml +- uses: actions/checkout@v5 +- uses: actions/setup-node@v6 + with: + node-version: '24' +- name: Run EOL Scan + env: + HD_ORG_ID: ${{ secrets.HD_ORG_ID }} + HD_AUTH_TOKEN: ${{ secrets.HD_AUTH_TOKEN }} + run: npx @herodevs/cli@beta scan eol -s +``` + +#### GitLab CI (authenticated scan) + +Add CI/CD variables `HD_AUTH_TOKEN` and `HD_ORG_ID` (masked) in your project: + +```yaml +eol-scan: + image: node:24 + variables: + HD_ORG_ID: $HD_ORG_ID + HD_AUTH_TOKEN: $HD_AUTH_TOKEN + script: + - npx @herodevs/cli@beta scan eol -s + artifacts: + paths: + - herodevs.report.json +``` + ### Using the Docker Image (Recommended) We provide a Docker image that's pre-configured to run EOL scans. Based on [`cdxgen`](https://github.com/CycloneDX/cdxgen), diff --git a/e2e/scan/eol.test.ts b/e2e/scan/eol.test.ts index 4b9ed7c0..e0b6610e 100644 --- a/e2e/scan/eol.test.ts +++ b/e2e/scan/eol.test.ts @@ -23,6 +23,17 @@ const upToDateDir = path.resolve(fixturesDir, 'npm/up-to-date'); const upToDateSbom = path.join(fixturesDir, 'npm/up-to-date.sbom.json'); const noComponentsSbom = path.join(fixturesDir, 'npm/no-components.sbom.json'); +function mockUserSetupStatus(orgId = 1) { + return { + eol: { + userSetupStatus: { + isComplete: true, + orgId, + }, + }, + }; +} + function mockReport(components: DeepPartial[] = []) { return { eol: { @@ -84,7 +95,10 @@ describe('scan:eol e2e', () => { nesRemediation: { remediations: [{ urls: { main: 'https://example.com' } }] }, }, ]; - fetchMock = new FetchMock().addGraphQL(mockReport(components)).addGraphQL(mockGetReport(components)); + fetchMock = new FetchMock() + .addGraphQL(mockUserSetupStatus()) + .addGraphQL(mockReport(components)) + .addGraphQL(mockGetReport(components)); }); afterEach(() => { @@ -243,7 +257,10 @@ describe('scan:eol e2e', () => { { purl: 'pkg:npm/vue@3.5.13', metadata: {} }, ]; fetchMock.restore(); - fetchMock = new FetchMock().addGraphQL(mockReport(components)).addGraphQL(mockGetReport(components)); + fetchMock = new FetchMock() + .addGraphQL(mockUserSetupStatus()) + .addGraphQL(mockReport(components)) + .addGraphQL(mockGetReport(components)); const cmd = `scan:eol --file ${upToDateSbom}`; const { stdout } = await run(cmd); match(stdout, /Scan results:/, 'Should show results header'); @@ -253,7 +270,10 @@ describe('scan:eol e2e', () => { it('handles empty components array without errors', async () => { fetchMock.restore(); - fetchMock = new FetchMock().addGraphQL(mockReport([])).addGraphQL(mockGetReport([])); + fetchMock = new FetchMock() + .addGraphQL(mockUserSetupStatus()) + .addGraphQL(mockReport([])) + .addGraphQL(mockGetReport([])); const cmd = `scan:eol --file ${noComponentsSbom}`; const { stdout } = await run(cmd); match(stdout, /No components found in scan/, 'Should show no packages found in scan'); @@ -276,7 +296,7 @@ describe('scan:eol e2e', () => { }); it('warns and skips saving when --sbomOutput is provided without --saveSbom', async () => { - const customDir = path.join(fixturesDir, 'sbom-outputs'); + const customDir = path.join(tmpdir(), 'scan-eol-sbom-output', randomUUID()); const customPath = path.join(customDir, 'custom-sbom.json'); await mkdir(customDir, { recursive: true }); @@ -398,7 +418,10 @@ describe('scan:eol e2e', () => { { purl: 'pkg:npm/bootstrap@5.3.5', metadata: {} }, { purl: 'pkg:npm/vue@3.5.13', metadata: {} }, ]; - fetchMock = new FetchMock().addGraphQL(mockReport(components)).addGraphQL(mockGetReport(components)); + fetchMock = new FetchMock() + .addGraphQL(mockUserSetupStatus()) + .addGraphQL(mockReport(components)) + .addGraphQL(mockGetReport(components)); const cmd = `scan:eol --dir ${upToDateDir}`; const { stdout } = await run(cmd); match(stdout, /Scan results:/, 'Should show results header'); @@ -592,7 +615,9 @@ describe('scan:eol e2e', () => { it('fails when NES returns unsuccessful result', async () => { // Override fetch mock to return unsuccessful mutation for this test fetchMock.restore(); - fetchMock = new FetchMock().addGraphQL({ eol: { createReport: { success: false, id: null, totalRecords: 0 } } }); + fetchMock = new FetchMock() + .addGraphQL(mockUserSetupStatus()) + .addGraphQL({ eol: { createReport: { success: false, id: null, totalRecords: 0 } } }); const out = await runExpectFail(`scan:eol --file ${simpleSbom}`); match( combinedOutputText(out), @@ -603,9 +628,11 @@ describe('scan:eol e2e', () => { it('fails when NES returns GraphQL errors', async () => { fetchMock.restore(); - fetchMock = new FetchMock().addGraphQL({ eol: { createReport: null } }, [ - { message: 'Internal server error', path: ['eol', 'createReport'] }, - ]); + fetchMock = new FetchMock() + .addGraphQL(mockUserSetupStatus()) + .addGraphQL({ eol: { createReport: null } }, [ + { message: 'Internal server error', path: ['eol', 'createReport'] }, + ]); const out = await runExpectFail(`scan:eol --file ${simpleSbom}`); match( combinedOutputText(out), diff --git a/package-lock.json b/package-lock.json index 83742284..7d0490df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@oclif/plugin-update": "^4.7.16", "@oclif/table": "^0.5.1", "cli-progress": "^3.12.0", + "conf": "^15.1.0", "date-fns": "^4.1.0", "glob": "^13.0.0", "graphql": "^16.11.0", @@ -7009,6 +7010,71 @@ "dev": true, "license": "MIT" }, + "node_modules/conf": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/conf/-/conf-15.1.0.tgz", + "integrity": "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^10.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.7.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/dot-prop": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", + "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", + "license": "MIT", + "dependencies": { + "type-fest": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/type-fest": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -7233,6 +7299,21 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/debounce-fn": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", + "integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -9662,6 +9743,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stringify-nice": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz", @@ -13272,6 +13359,18 @@ "node": ">=8" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar": { "version": "7.5.2", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", @@ -13630,6 +13729,18 @@ "node": ">=14.17" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz", diff --git a/package.json b/package.json index 3655e9b7..b9e985ef 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@oclif/plugin-update": "^4.7.16", "@oclif/table": "^0.5.1", "cli-progress": "^3.12.0", + "conf": "^15.1.0", "date-fns": "^4.1.0", "glob": "^13.0.0", "graphql": "^16.11.0", diff --git a/src/api/apollo.client.ts b/src/api/apollo.client.ts new file mode 100644 index 00000000..846bd3f6 --- /dev/null +++ b/src/api/apollo.client.ts @@ -0,0 +1,69 @@ +import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client/core'; +import { config } from '../config/constants.ts'; +import { requireAccessTokenForScan } from '../service/auth.svc.ts'; + +export type TokenProvider = (forceRefresh?: boolean) => Promise; + +function isTokenEndpoint(input: string | URL | Request): boolean { + let urlString: string; + if (typeof input === 'string') { + urlString = input; + } else if (input instanceof Request) { + urlString = input.url; + } else { + urlString = input.toString(); + } + + try { + const url = new URL(urlString); + return url.pathname.endsWith('/token'); + } catch { + const pathOnly = urlString.split('?')[0].split('#')[0]; + return pathOnly.endsWith('/token'); + } +} + +const createAuthorizedFetch = + (tokenProvider: TokenProvider): typeof fetch => + async (input, init) => { + const headers = new Headers(init?.headers); + + if (config.enableAuth) { + const token = await tokenProvider(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + } + + const response = await fetch(input, { ...init, headers }); + + if ( + config.enableAuth && + response.status === 401 && + !isTokenEndpoint(input) && + (init?.method === 'GET' || init?.method === undefined || init?.method === 'POST') + ) { + const refreshed = await tokenProvider(true); + const retryHeaders = new Headers(init?.headers); + retryHeaders.set('Authorization', `Bearer ${refreshed}`); + return fetch(input, { ...init, headers: retryHeaders }); + } + + return response; + }; + +export const createApollo = (uri: string, tokenProvider: TokenProvider = requireAccessTokenForScan) => + new ApolloClient({ + cache: new InMemoryCache(), + defaultOptions: { + query: { fetchPolicy: 'no-cache', errorPolicy: 'all' }, + mutate: { errorPolicy: 'all' }, + }, + link: new HttpLink({ + uri, + fetch: createAuthorizedFetch(tokenProvider), + headers: { + 'User-Agent': `hdcli/${process.env.npm_package_version ?? 'unknown'}`, + }, + }), + }); diff --git a/src/api/ci-token.client.ts b/src/api/ci-token.client.ts new file mode 100644 index 00000000..7985f2c9 --- /dev/null +++ b/src/api/ci-token.client.ts @@ -0,0 +1,152 @@ +import type { GraphQLFormattedError } from 'graphql'; +import { config } from '../config/constants.ts'; +import { requireAccessToken } from '../service/auth.svc.ts'; +import { isAccessTokenExpired } from '../service/auth-token.svc.ts'; +import { debugLogger } from '../service/log.svc.ts'; +import { createApollo } from './apollo.client.ts'; +import { ApiError, type ApiErrorCode, isApiErrorCode } from './errors.ts'; +import { getOrgAccessTokensMutation } from './gql-operations.ts'; +import { getGraphQLErrors } from './graphql-errors.ts'; + +const graphqlUrl = `${config.iamHost}${config.iamPath}`; + +const noAuthTokenProvider = async (): Promise => ''; + +function createOptionalTokenProvider(token?: string) { + return async (): Promise => { + if (token && !isAccessTokenExpired(token)) { + return token; + } + return ''; + }; +} + +export interface IamAccessOrgTokensInput { + orgId: number | null; + previousToken: string | null; +} + +export interface ProvisionCITokenResponse { + refresh_token: string; +} + +export interface ProvisionCITokenOptions { + orgId?: number | null; + previousToken?: string | null; +} + +type GetOrgAccessTokensResponse = { + iamV2?: { + access?: { + getOrgAccessTokens?: { accessToken?: string; refreshToken?: string }; + }; + }; +}; + +function extractErrorCode(errors: ReadonlyArray): ApiErrorCode | undefined { + const code = (errors[0]?.extensions as { code?: string })?.code; + if (!code || !isApiErrorCode(code)) return; + return code; +} + +async function getOrgAccessTokens( + input: IamAccessOrgTokensInput, +): Promise<{ accessToken: string; refreshToken: string }> { + const client = createApollo(graphqlUrl, requireAccessToken); + const res = await client.mutate({ + mutation: getOrgAccessTokensMutation, + variables: { + input, + }, + }); + + const errors = getGraphQLErrors(res); + if (res?.error || errors?.length) { + debugLogger('Error returned from getOrgAccessTokens mutation: %o', res.error ?? errors); + if (errors?.length) { + const code = extractErrorCode(errors); + if (code) { + throw new ApiError(errors[0].message ?? 'CI token provisioning failed', code); + } + throw new Error(errors[0].message ?? 'CI token provisioning failed'); + } + const msg = res?.error instanceof Error ? res.error.message : res?.error ? String(res.error) : ''; + throw new Error(msg || 'CI token provisioning failed'); + } + + const tokens = res.data?.iamV2?.access?.getOrgAccessTokens; + if (!tokens?.refreshToken || tokens.refreshToken.trim() === '') { + throw new Error('CI token provisioning response missing refreshToken'); + } + + return { + accessToken: tokens.accessToken ?? '', + refreshToken: tokens.refreshToken, + }; +} + +export async function getOrgAccessTokensUnauthenticated( + input: IamAccessOrgTokensInput, +): Promise<{ accessToken: string; refreshToken: string }> { + return callGetOrgAccessTokensInternal(input, noAuthTokenProvider); +} + +type TokenProvider = () => Promise; + +async function callGetOrgAccessTokensInternal( + input: IamAccessOrgTokensInput, + tokenProvider: TokenProvider, +): Promise<{ accessToken: string; refreshToken: string }> { + const client = createApollo(graphqlUrl, tokenProvider); + const res = await client.mutate({ + mutation: getOrgAccessTokensMutation, + variables: { input }, + }); + + const errors = getGraphQLErrors(res); + if (res?.error || errors?.length) { + debugLogger('Error returned from getOrgAccessTokens mutation: %o', res.error ?? errors); + if (errors?.length) { + const code = extractErrorCode(errors); + if (code) { + throw new ApiError(errors[0].message ?? 'CI token refresh failed', code); + } + throw new Error(errors[0].message ?? 'CI token refresh failed'); + } + const msg = res?.error instanceof Error ? res.error.message : res?.error ? String(res.error) : ''; + throw new Error(msg || 'CI token refresh failed'); + } + + const tokens = res.data?.iamV2?.access?.getOrgAccessTokens; + if (!tokens?.accessToken) { + throw new Error('getOrgAccessTokens response missing accessToken'); + } + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken ?? '', + }; +} + +export interface ExchangeCITokenOptions { + refreshToken: string; + orgId: number; + optionalAccessToken?: string; +} + +export async function exchangeCITokenForAccess( + options: ExchangeCITokenOptions, +): Promise<{ accessToken: string; refreshToken: string }> { + const { refreshToken, orgId, optionalAccessToken } = options; + const tokenProvider = createOptionalTokenProvider(optionalAccessToken); + return callGetOrgAccessTokensInternal({ orgId, previousToken: refreshToken }, tokenProvider); +} + +export async function provisionCIToken(options: ProvisionCITokenOptions = {}): Promise { + const { orgId = null, previousToken = null } = options; + const result = await getOrgAccessTokens({ + orgId, + previousToken, + }); + return { refresh_token: result.refreshToken }; +} diff --git a/src/api/gql-operations.ts b/src/api/gql-operations.ts index 28882b3f..070fd530 100644 --- a/src/api/gql-operations.ts +++ b/src/api/gql-operations.ts @@ -41,7 +41,10 @@ query GetEolReport($input: GetEolReportInput) { export const userSetupStatusQuery = gql` query Eol { eol { - userSetupStatus + userSetupStatus { + isComplete + orgId + } } } `; @@ -49,7 +52,25 @@ query Eol { export const completeUserSetupMutation = gql` mutation Eol { eol { - completeUserSetup + completeUserSetup { + isComplete + orgId + } + } +} +`; + +export const getOrgAccessTokensMutation = gql` +mutation GetOrgAccessTokens( + $input: IamAccessOrgTokensInput! +) { + iamV2 { + access { + getOrgAccessTokens(input: $input) { + accessToken + refreshToken + } + } } } `; diff --git a/src/api/nes.client.ts b/src/api/nes.client.ts index 3dc93d54..4771ac07 100644 --- a/src/api/nes.client.ts +++ b/src/api/nes.client.ts @@ -1,4 +1,3 @@ -import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client/core'; import type { CreateEolReportInput, EolReport, @@ -8,48 +7,19 @@ import type { } from '@herodevs/eol-shared'; import type { GraphQLFormattedError } from 'graphql'; import { config } from '../config/constants.ts'; -import { requireAccessTokenForScan } from '../service/auth.svc.ts'; import { debugLogger } from '../service/log.svc.ts'; import { stripTypename } from '../utils/strip-typename.ts'; +import { createApollo } from './apollo.client.ts'; import { ApiError, type ApiErrorCode, isApiErrorCode } from './errors.ts'; import { createReportMutation, getEolReportQuery } from './gql-operations.ts'; import { getGraphQLErrors } from './graphql-errors.ts'; -type TokenProvider = () => Promise; - -const createAuthorizedFetch = - (tokenProvider: TokenProvider): typeof fetch => - async (input, init) => { - const headers = new Headers(init?.headers); - - const token = await tokenProvider(); - headers.set('Authorization', `Bearer ${token}`); - - return fetch(input, { ...init, headers }); - }; - function extractErrorCode(errors: ReadonlyArray): ApiErrorCode | undefined { const code = (errors[0]?.extensions as { code?: string })?.code; if (!code || !isApiErrorCode(code)) return; return code; } -export const createApollo = (uri: string, tokenProvider: TokenProvider = requireAccessTokenForScan) => - new ApolloClient({ - cache: new InMemoryCache(), - defaultOptions: { - query: { fetchPolicy: 'no-cache', errorPolicy: 'all' }, - mutate: { errorPolicy: 'all' }, - }, - link: new HttpLink({ - uri, - fetch: createAuthorizedFetch(tokenProvider), - headers: { - 'User-Agent': `hdcli/${process.env.npm_package_version ?? 'unknown'}`, - }, - }), - }); - export const SbomScanner = (client: ReturnType) => { return async (input: CreateEolReportInput): Promise => { let res: Awaited>>; diff --git a/src/api/user-setup.client.ts b/src/api/user-setup.client.ts index 60e80137..1191fb15 100644 --- a/src/api/user-setup.client.ts +++ b/src/api/user-setup.client.ts @@ -1,25 +1,30 @@ import type { GraphQLFormattedError } from 'graphql'; import { config } from '../config/constants.ts'; -import { requireAccessToken } from '../service/auth.svc.ts'; +import { requireAccessToken, requireAccessTokenForScan } from '../service/auth.svc.ts'; +import { getCIToken } from '../service/ci-token.svc.ts'; import { debugLogger } from '../service/log.svc.ts'; import { withRetries } from '../utils/retry.ts'; +import { createApollo } from './apollo.client.ts'; import { ApiError, type ApiErrorCode, isApiErrorCode } from './errors.ts'; import { completeUserSetupMutation, userSetupStatusQuery } from './gql-operations.ts'; import { getGraphQLErrors } from './graphql-errors.ts'; -import { createApollo } from './nes.client.ts'; const USER_SETUP_MAX_ATTEMPTS = 3; const USER_SETUP_RETRY_DELAY_MS = 500; +const USER_FACING_SERVER_ERROR = 'Please contact your administrator.'; +const SERVER_ERROR_CODES = ['INTERNAL_SERVER_ERROR', 'SERVER_ERROR', 'SERVICE_UNAVAILABLE']; + +type UserSetupStatusData = { isComplete: boolean; orgId?: number | null }; type UserSetupStatusResponse = { eol?: { - userSetupStatus?: boolean; + userSetupStatus?: UserSetupStatusData; }; }; type CompleteUserSetupResponse = { eol?: { - completeUserSetup?: boolean; + completeUserSetup?: UserSetupStatusData; }; }; @@ -31,67 +36,85 @@ function extractErrorCode(errors: ReadonlyArray): ApiErro return code; } -export async function getUserSetupStatus(): Promise { - const client = createApollo(getGraphqlUrl(), requireAccessToken); +export async function getUserSetupStatus(): Promise<{ isComplete: boolean; orgId?: number | null }> { + const tokenProvider = getCIToken() || config.accessTokenFromEnv ? requireAccessTokenForScan : requireAccessToken; + const client = createApollo(getGraphqlUrl(), tokenProvider); const res = await client.query({ query: userSetupStatusQuery }); const errors = getGraphQLErrors(res); if (res?.error || errors?.length) { debugLogger('Error returned from userSetupStatus query: %o', res.error || errors); if (errors?.length) { + const rawCode = (errors[0]?.extensions as { code?: string })?.code; + if (rawCode && SERVER_ERROR_CODES.includes(rawCode)) { + throw new Error(USER_FACING_SERVER_ERROR); + } const code = extractErrorCode(errors); + const message = errors[0].message ?? 'Failed to check user setup status'; if (code) { - throw new ApiError(errors[0].message, code); + throw new ApiError(message, code); } + throw new Error(message); } throw new Error('Failed to check user setup status'); } - const isComplete = res.data?.eol?.userSetupStatus; - if (typeof isComplete !== 'boolean') { + const status = res.data?.eol?.userSetupStatus; + if (!status || typeof status.isComplete !== 'boolean') { debugLogger('Unexpected userSetupStatus query response: %o', res.data); throw new Error('Failed to check user setup status'); } - return isComplete; + return { isComplete: status.isComplete, orgId: status.orgId ?? undefined }; } -export async function completeUserSetup(): Promise { - const client = createApollo(getGraphqlUrl(), requireAccessToken); +export async function completeUserSetup(): Promise<{ isComplete: boolean; orgId?: number | null }> { + const client = createApollo(getGraphqlUrl(), requireAccessTokenForScan); const res = await client.mutate({ mutation: completeUserSetupMutation }); const errors = getGraphQLErrors(res); if (res?.error || errors?.length) { debugLogger('Error returned from completeUserSetup mutation: %o', res.error || errors); if (errors?.length) { + const rawCode = (errors[0]?.extensions as { code?: string })?.code; + if (rawCode && SERVER_ERROR_CODES.includes(rawCode)) { + throw new Error(USER_FACING_SERVER_ERROR); + } const code = extractErrorCode(errors); + const message = errors[0].message ?? 'Failed to complete user setup'; if (code) { - throw new ApiError(errors[0].message, code); + throw new ApiError(message, code); } + throw new Error(message); } throw new Error('Failed to complete user setup'); } - const success = res.data?.eol?.completeUserSetup; - if (!success) { + const result = res.data?.eol?.completeUserSetup; + if (!result || result.isComplete !== true) { debugLogger('completeUserSetup mutation returned unsuccessful response: %o', res.data); throw new Error('Failed to complete user setup'); } - return success; + return { isComplete: true, orgId: result.orgId ?? undefined }; } -export async function ensureUserSetup(): Promise { - const isComplete = await withRetries('user-setup-status', () => getUserSetupStatus(), { +export async function ensureUserSetup(): Promise { + const status = await withRetries('user-setup-status', () => getUserSetupStatus(), { attempts: USER_SETUP_MAX_ATTEMPTS, baseDelayMs: USER_SETUP_RETRY_DELAY_MS, }); - if (isComplete) { - return; + if (status.isComplete && status.orgId != null) { + return status.orgId; } - await withRetries('user-setup-complete', () => completeUserSetup(), { + const result = await withRetries('user-setup-complete', () => completeUserSetup(), { attempts: USER_SETUP_MAX_ATTEMPTS, baseDelayMs: USER_SETUP_RETRY_DELAY_MS, }); + if (result.orgId != null) { + return result.orgId; + } + + throw new Error('User setup did not return an organization ID. Please contact your administrator.'); } diff --git a/src/commands/auth/provision-ci-token.ts b/src/commands/auth/provision-ci-token.ts new file mode 100644 index 00000000..125560bd --- /dev/null +++ b/src/commands/auth/provision-ci-token.ts @@ -0,0 +1,41 @@ +import { Command } from '@oclif/core'; +import { provisionCIToken } from '../../api/ci-token.client.ts'; +import { ensureUserSetup } from '../../api/user-setup.client.ts'; +import { requireAccessToken } from '../../service/auth.svc.ts'; +import { saveCIOrgId, saveCIToken } from '../../service/ci-token.svc.ts'; +import { getErrorMessage } from '../../service/log.svc.ts'; + +export default class AuthProvisionCiToken extends Command { + static override description = 'Provision a CI/CD long-lived refresh token for headless auth'; + + async run() { + await this.parse(AuthProvisionCiToken); + + try { + await requireAccessToken(); + } catch (error) { + this.error(`Must be logged in to provision CI token. Run 'hd auth login' first. ${getErrorMessage(error)}`); + } + + let orgId: number; + try { + orgId = await ensureUserSetup(); + } catch (error) { + this.error(`User setup failed. ${getErrorMessage(error)}`); + } + + try { + const result = await provisionCIToken({ orgId }); + const refreshToken = result.refresh_token; + saveCIToken(refreshToken); + saveCIOrgId(orgId); + this.log('CI token provisioned and saved locally.'); + this.log(''); + this.log('For CI/CD, set these environment variables:'); + this.log(` HD_ORG_ID=${orgId}`); + this.log(` HD_AUTH_TOKEN=${refreshToken}`); + } catch (error) { + this.error(`CI token provisioning failed. ${getErrorMessage(error)}`); + } + } +} diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 91ec4f2c..6e43fe3b 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -4,6 +4,7 @@ import { Command, Flags } from '@oclif/core'; import ora from 'ora'; import { ApiError } from '../../api/errors.ts'; import { submitScan } from '../../api/nes.client.ts'; +import { ensureUserSetup } from '../../api/user-setup.client.ts'; import { config, filenamePrefix, SCAN_ORIGIN_AUTOMATED, SCAN_ORIGIN_CLI } from '../../config/constants.ts'; import { track } from '../../service/analytics.svc.ts'; import { requireAccessTokenForScan } from '../../service/auth.svc.ts'; @@ -141,6 +142,7 @@ export default class ScanEol extends Command { } const scanStartTime = performance.now(); + await ensureUserSetup(); const scan = await this.scanSbom(sbom); const componentCounts = countComponentsByStatus(scan); @@ -236,7 +238,8 @@ export default class ScanEol extends Command { UNAUTHENTICATED: 'Please log in to perform a scan. To authenticate, run "hd auth login".', FORBIDDEN: 'You do not have permission to perform this action.', }; - this.error(errorMessages[error.code]); + const message = errorMessages[error.code] ?? error.message?.trim(); + this.error(message); } const errorMessage = getErrorMessage(error); diff --git a/src/config/constants.ts b/src/config/constants.ts index 5505a2a3..954f5342 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,6 +1,8 @@ export const EOL_REPORT_URL = 'https://apps.herodevs.com/eol/reports'; export const GRAPHQL_HOST = 'https://gateway.prod.apps.herodevs.io'; export const GRAPHQL_PATH = '/graphql'; +export const IAM_HOST = 'http://iam-dev:5845'; +export const IAM_PATH = '/graphql'; export const ANALYTICS_URL = 'https://apps.herodevs.com/api/eol/track'; export const CONCURRENT_PAGE_REQUESTS = 3; export const PAGE_SIZE = 500; @@ -10,6 +12,9 @@ export const GIT_OUTPUT_FORMAT = `"${['%h', '%an', '%ad'].join('|')}"`; export const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd'; export const DEFAULT_DATE_COMMIT_FORMAT = 'MM/dd/yyyy, h:mm:ss a'; export const DEFAULT_DATE_COMMIT_MONTH_FORMAT = 'MMMM yyyy'; +export const ENABLE_AUTH = false; +export const ENABLE_USER_SETUP = false; + // Trackers - Constants export const DEFAULT_TRACKER_RUN_DATA_FILE = 'data.json'; export const TRACKER_GIT_OUTPUT_FORMAT = `"${['%H', '%an', '%ad'].join('|')}"`; @@ -26,13 +31,29 @@ if (parsedPageSize > 0) { pageSize = parsedPageSize; } +const enableAuthEnv = process.env.ENABLE_AUTH; +const enableAuth = enableAuthEnv === 'true' ? true : enableAuthEnv === 'false' ? false : ENABLE_AUTH; +const enableUserSetupEnv = process.env.ENABLE_USER_SETUP; +const enableUserSetup = + enableUserSetupEnv === 'true' ? true : enableUserSetupEnv === 'false' ? false : ENABLE_USER_SETUP; +const orgIdEnv = process.env.HD_ORG_ID?.trim(); +const orgIdParsed = orgIdEnv ? Number.parseInt(orgIdEnv, 10) : NaN; +const orgIdFromEnv = Number.isInteger(orgIdParsed) && orgIdParsed >= 1 ? orgIdParsed : undefined; + export const config = { eolReportUrl: process.env.EOL_REPORT_URL || EOL_REPORT_URL, graphqlHost: process.env.GRAPHQL_HOST || GRAPHQL_HOST, graphqlPath: process.env.GRAPHQL_PATH || GRAPHQL_PATH, + iamHost: process.env.IAM_HOST || IAM_HOST, + iamPath: process.env.IAM_PATH || IAM_PATH, analyticsUrl: process.env.ANALYTICS_URL || ANALYTICS_URL, concurrentPageRequests, pageSize, + enableAuth, + enableUserSetup, + ciTokenFromEnv: process.env.HD_AUTH_TOKEN?.trim() || undefined, + orgIdFromEnv, + accessTokenFromEnv: process.env.HD_ACCESS_TOKEN?.trim() || undefined, }; export const filenamePrefix = 'herodevs'; diff --git a/src/service/auth.svc.ts b/src/service/auth.svc.ts index 65c75cb9..cf7fdf56 100644 --- a/src/service/auth.svc.ts +++ b/src/service/auth.svc.ts @@ -1,8 +1,14 @@ +import { config } from '../config/constants.ts'; import type { TokenResponse } from '../types/auth.ts'; import { refreshTokens } from './auth-refresh.svc.ts'; import { clearStoredTokens, getStoredTokens, isAccessTokenExpired, saveTokens } from './auth-token.svc.ts'; +import { requireCIAccessToken } from './ci-auth.svc.ts'; +import { getCIToken } from './ci-token.svc.ts'; import { debugLogger } from './log.svc.ts'; +export type { CITokenErrorCode } from './ci-auth.svc.ts'; +export { CITokenError } from './ci-auth.svc.ts'; + export type AuthErrorCode = 'NOT_LOGGED_IN' | 'SESSION_EXPIRED'; export class AuthError extends Error { @@ -55,6 +61,10 @@ export async function logoutLocally() { } export async function requireAccessTokenForScan(): Promise { + if (getCIToken() || config.accessTokenFromEnv) { + return requireCIAccessToken(); + } + const tokens = await getStoredTokens(); if (!tokens?.accessToken) { @@ -71,7 +81,6 @@ export async function requireAccessTokenForScan(): Promise { await persistTokenResponse(newTokens); return newTokens.access_token; } catch (error) { - // Refresh failed - fall through to session expired error debugLogger('Token refresh failed: %O', error); } } diff --git a/src/service/ci-auth.svc.ts b/src/service/ci-auth.svc.ts new file mode 100644 index 00000000..7a2b7f95 --- /dev/null +++ b/src/service/ci-auth.svc.ts @@ -0,0 +1,54 @@ +import { exchangeCITokenForAccess } from '../api/ci-token.client.ts'; +import { config } from '../config/constants.ts'; +import { isAccessTokenExpired } from './auth-token.svc.ts'; +import { getCIOrgId, getCIToken, saveCIToken } from './ci-token.svc.ts'; +import { debugLogger } from './log.svc.ts'; + +export type CITokenErrorCode = 'CI_TOKEN_INVALID' | 'CI_TOKEN_REFRESH_FAILED' | 'CI_ORG_ID_REQUIRED'; + +const CITOKEN_ERROR_MESSAGE = + "CI token is invalid or expired. To provision a new CI token, run 'hd auth provision-ci-token' (after logging in with 'hd auth login')."; + +const CI_ORG_ID_ERROR_MESSAGE = + 'Organization ID is required for CI token. When using HD_AUTH_TOKEN, set HD_ORG_ID to your organization ID (e.g. HD_ORG_ID=123). When using a locally stored CI token, re-provision with: hd auth provision-ci-token'; + +export class CITokenError extends Error { + readonly code: CITokenErrorCode; + + constructor(message: string, code: CITokenErrorCode) { + super(message); + this.name = 'CITokenError'; + this.code = code; + } +} + +export async function requireCIAccessToken(): Promise { + if (config.accessTokenFromEnv && !isAccessTokenExpired(config.accessTokenFromEnv)) { + return config.accessTokenFromEnv; + } + + const ciToken = getCIToken(); + if (!ciToken) { + throw new CITokenError(CITOKEN_ERROR_MESSAGE, 'CI_TOKEN_INVALID'); + } + + const orgId = config.ciTokenFromEnv !== undefined ? config.orgIdFromEnv : getCIOrgId(); + if (orgId === undefined) { + throw new CITokenError(CI_ORG_ID_ERROR_MESSAGE, 'CI_ORG_ID_REQUIRED'); + } + + try { + const result = await exchangeCITokenForAccess({ + refreshToken: ciToken, + orgId, + optionalAccessToken: config.accessTokenFromEnv, + }); + if (result.refreshToken && config.ciTokenFromEnv === undefined) { + saveCIToken(result.refreshToken); + } + return result.accessToken; + } catch (error) { + debugLogger('CI token refresh failed: %O', error); + throw new CITokenError(CITOKEN_ERROR_MESSAGE, 'CI_TOKEN_REFRESH_FAILED'); + } +} diff --git a/src/service/ci-token.svc.ts b/src/service/ci-token.svc.ts new file mode 100644 index 00000000..ba31210b --- /dev/null +++ b/src/service/ci-token.svc.ts @@ -0,0 +1,104 @@ +import crypto from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import Conf from 'conf'; +import { config } from '../config/constants.ts'; + +const CI_TOKEN_STORAGE_KEY = 'ciRefreshToken'; +const CI_ORG_ID_STORAGE_KEY = 'ciOrgId'; +const ENCRYPTION_SALT = 'hdcli-ci-token-v1'; +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; + +function getConfStore(): Conf> { + const cwd = path.join(os.homedir(), '.hdcli'); + return new Conf>({ + projectName: 'hdcli', + cwd, + configName: 'ci-token', + }); +} + +function getMachineKey(): Buffer { + const hostname = os.hostname(); + const username = os.userInfo().username; + const raw = `${hostname}:${username}:${ENCRYPTION_SALT}`; + return crypto.createHash('sha256').update(raw, 'utf8').digest(); +} + +export function encryptToken(plaintext: string): string { + const key = getMachineKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + const combined = Buffer.concat([iv, authTag, encrypted]); + return combined.toString('base64url'); +} + +export function decryptToken(encoded: string): string { + const key = getMachineKey(); + const combined = Buffer.from(encoded, 'base64url'); + if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH) { + throw new Error('Invalid encrypted token format'); + } + const iv = combined.subarray(0, IV_LENGTH); + const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const ciphertext = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + return decipher.update(ciphertext).toString('utf8') + decipher.final('utf8'); +} + +export function getCITokenFromStorage(): string | undefined { + const store = getConfStore(); + const encoded = store.get(CI_TOKEN_STORAGE_KEY) as string | undefined; + if (encoded === undefined || typeof encoded !== 'string') { + return undefined; + } + try { + return decryptToken(encoded); + } catch { + return undefined; + } +} + +export function getCIToken(): string | undefined { + const fromEnv = config.ciTokenFromEnv; + if (fromEnv !== undefined) { + return fromEnv; + } + return getCITokenFromStorage(); +} + +export function saveCIToken(token: string): void { + const store = getConfStore(); + const encoded = encryptToken(token); + store.set(CI_TOKEN_STORAGE_KEY, encoded); +} + +export function clearCIToken(): void { + const store = getConfStore(); + store.delete(CI_TOKEN_STORAGE_KEY); + store.delete(CI_ORG_ID_STORAGE_KEY); +} + +export function getCIOrgId(): number | undefined { + const store = getConfStore(); + const value = store.get(CI_ORG_ID_STORAGE_KEY); + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { + return undefined; + } + return value; +} + +export function saveCIOrgId(orgId: number): void { + const store = getConfStore(); + store.set(CI_ORG_ID_STORAGE_KEY, orgId); +} + +export function clearCIOrgId(): void { + const store = getConfStore(); + store.delete(CI_ORG_ID_STORAGE_KEY); +} diff --git a/src/utils/retry.ts b/src/utils/retry.ts index 77a6312f..9aee9db2 100644 --- a/src/utils/retry.ts +++ b/src/utils/retry.ts @@ -11,11 +11,13 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export async function withRetries(operation: string, fn: () => Promise, options: RetryOptions): Promise { const { attempts, baseDelayMs, onRetry, finalErrorMessage } = options; + let lastError: unknown; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { return await fn(); } catch (error) { + lastError = error; if (attempt === attempts) { break; } @@ -30,5 +32,9 @@ export async function withRetries(operation: string, fn: () => Promise, op } } - throw new Error(finalErrorMessage ?? 'Please contact your administrator.'); + const message = + finalErrorMessage ?? + (lastError instanceof Error ? lastError.message : null) ?? + 'Please contact your administrator.'; + throw new Error(message); } diff --git a/test/api/ci-token.client.test.ts b/test/api/ci-token.client.test.ts new file mode 100644 index 00000000..3694aabf --- /dev/null +++ b/test/api/ci-token.client.test.ts @@ -0,0 +1,294 @@ +import { vi } from 'vitest'; + +vi.mock('../../src/service/auth.svc.ts', () => ({ + requireAccessToken: vi.fn(() => Promise.resolve('access-token')), +})); + +vi.mock('../../src/config/constants.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + config: { + ...actual.config, + iamHost: 'https://iam.test', + iamPath: '/graphql', + enableAuth: true, + }, + }; +}); + +import { + exchangeCITokenForAccess, + getOrgAccessTokensUnauthenticated, + provisionCIToken, +} from '../../src/api/ci-token.client.ts'; +import { FetchMock } from '../utils/mocks/fetch.mock.ts'; + +const mockHeaders = { + get: () => 'application/json', +}; + +function mockGraphQLResponse(data: { + iamV2?: { + access?: { + getOrgAccessTokens?: { accessToken?: string; refreshToken?: string }; + }; + }; +}) { + const payload = { data }; + return { + ok: true, + status: 200, + headers: mockHeaders, + async json() { + return payload; + }, + async text() { + return JSON.stringify(payload); + }, + } as unknown as Response; +} + +function mockGraphQLErrorResponse(message: string) { + const payload = { errors: [{ message }] }; + return { + ok: true, + status: 200, + headers: mockHeaders, + async json() { + return payload; + }, + async text() { + return JSON.stringify(payload); + }, + } as unknown as Response; +} + +function mockErrorResponse(status: number, body: string) { + return { + ok: false, + status, + headers: mockHeaders, + async text() { + return body; + }, + async json() { + try { + return JSON.parse(body); + } catch { + return {}; + } + }, + } as unknown as Response; +} + +describe('ci-token.client', () => { + let fetchMock: FetchMock; + + beforeEach(() => { + fetchMock = new FetchMock(); + }); + + afterEach(() => { + fetchMock.restore(); + }); + + it('returns refresh_token on success', async () => { + fetchMock.push( + mockGraphQLResponse({ + iamV2: { + access: { + getOrgAccessTokens: { + accessToken: 'new-access', + refreshToken: 'ci-refresh-token-123', + }, + }, + }, + }), + ); + + const result = await provisionCIToken(); + expect(result).toEqual({ refresh_token: 'ci-refresh-token-123' }); + + const calls = fetchMock.getCalls(); + expect(calls).toHaveLength(1); + expect(calls[0].input).toContain('graphql'); + const headers = calls[0].init?.headers as Headers | undefined; + expect(headers?.get?.('Authorization')).toBe('Bearer access-token'); + expect(headers?.get?.('Content-Type')).toBe('application/json'); + const body = JSON.parse((calls[0].init?.body as string) ?? '{}'); + expect(body.variables).toEqual({ + input: { orgId: null, previousToken: null }, + }); + }); + + it('passes orgId and previousToken when provided', async () => { + fetchMock.push( + mockGraphQLResponse({ + iamV2: { + access: { + getOrgAccessTokens: { + accessToken: 'a', + refreshToken: 'r', + }, + }, + }, + }), + ); + + await provisionCIToken({ + orgId: 42, + previousToken: 'old-refresh', + }); + + const body = JSON.parse((fetchMock.getCalls()[0].init?.body as string) ?? '{}'); + expect(body.variables.input).toEqual({ + orgId: 42, + previousToken: 'old-refresh', + }); + }); + + it('throws when response is not ok', async () => { + fetchMock.push(mockErrorResponse(401, 'Unauthorized')); + fetchMock.push(mockErrorResponse(401, 'Unauthorized')); // retry consumes second mock + + await expect(provisionCIToken()).rejects.toThrow(/401/); + }); + + it('throws when GraphQL returns errors', async () => { + fetchMock.push(mockGraphQLErrorResponse('Not authorized')); + + await expect(provisionCIToken()).rejects.toThrow(/Not authorized/); + }); + + it('throws when response body is missing refreshToken', async () => { + fetchMock.push(mockGraphQLResponse({})); + + await expect(provisionCIToken()).rejects.toThrow(/missing refreshToken/); + }); + + it('throws when refreshToken is empty string', async () => { + fetchMock.push( + mockGraphQLResponse({ + iamV2: { + access: { + getOrgAccessTokens: { + accessToken: 'a', + refreshToken: ' ', + }, + }, + }, + }), + ); + + await expect(provisionCIToken()).rejects.toThrow(/missing refreshToken/); + }); + + describe('getOrgAccessTokensUnauthenticated', () => { + it('calls IAM with orgId and previousToken, without Bearer header', async () => { + fetchMock.push( + mockGraphQLResponse({ + iamV2: { + access: { + getOrgAccessTokens: { + accessToken: 'new-access-from-refresh', + refreshToken: 'new-refresh', + }, + }, + }, + }), + ); + + const result = await getOrgAccessTokensUnauthenticated({ + orgId: 42, + previousToken: 'stored-ci-refresh-token', + }); + expect(result.accessToken).toBe('new-access-from-refresh'); + expect(result.refreshToken).toBe('new-refresh'); + + const calls = fetchMock.getCalls(); + expect(calls).toHaveLength(1); + const headers = calls[0].init?.headers as Record | Headers | undefined; + const authHeader = + headers && typeof (headers as Headers).get === 'function' + ? (headers as Headers).get('Authorization') + : (headers as Record)?.Authorization; + expect(authHeader).toBeFalsy(); + const body = JSON.parse((calls[0].init?.body as string) ?? '{}'); + expect(body.variables.input).toEqual({ + orgId: 42, + previousToken: 'stored-ci-refresh-token', + }); + }); + + it('throws when GraphQL returns errors', async () => { + fetchMock.push(mockGraphQLErrorResponse('Invalid refresh token')); + + await expect(getOrgAccessTokensUnauthenticated({ orgId: 1, previousToken: 'bad-token' })).rejects.toThrow( + /Invalid refresh token/, + ); + }); + + it('throws when response is not ok', async () => { + fetchMock.push(mockErrorResponse(500, 'Internal Server Error')); + + await expect(getOrgAccessTokensUnauthenticated({ orgId: 1, previousToken: 'token' })).rejects.toThrow(/500/); + }); + }); + + describe('exchangeCITokenForAccess', () => { + it('calls IAM with orgId and previousToken, returns access and refresh token', async () => { + fetchMock.push( + mockGraphQLResponse({ + iamV2: { + access: { + getOrgAccessTokens: { + accessToken: 'exchanged-access', + refreshToken: 'exchanged-refresh', + }, + }, + }, + }), + ); + + const result = await exchangeCITokenForAccess({ + refreshToken: 'ci-refresh', + orgId: 99, + }); + expect(result.accessToken).toBe('exchanged-access'); + expect(result.refreshToken).toBe('exchanged-refresh'); + + const body = JSON.parse((fetchMock.getCalls()[0].init?.body as string) ?? '{}'); + expect(body.variables.input).toEqual({ + orgId: 99, + previousToken: 'ci-refresh', + }); + }); + + it('sends Bearer header when optionalAccessToken is valid JWT', async () => { + const validJwt = 'eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjk5OTk5OTk5OTl9.xxx'; + + fetchMock.push( + mockGraphQLResponse({ + iamV2: { + access: { + getOrgAccessTokens: { + accessToken: 'access', + refreshToken: 'refresh', + }, + }, + }, + }), + ); + + await exchangeCITokenForAccess({ + refreshToken: 'ci-refresh', + orgId: 1, + optionalAccessToken: validJwt, + }); + + const headers = fetchMock.getCalls()[0].init?.headers as Headers | undefined; + expect(headers?.get?.('Authorization')).toBe(`Bearer ${validJwt}`); + }); + }); +}); diff --git a/test/api/nes.client.test.ts b/test/api/nes.client.test.ts index df9e6eaf..f3703995 100644 --- a/test/api/nes.client.test.ts +++ b/test/api/nes.client.test.ts @@ -1,5 +1,14 @@ import type { CreateEolReportInput } from '@herodevs/eol-shared'; import { vi } from 'vitest'; + +vi.mock('../../src/config/constants.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + config: { ...actual.config, enableAuth: false }, + }; +}); + import { submitScan } from '../../src/api/nes.client.ts'; import { SCAN_ORIGIN_AUTOMATED, SCAN_ORIGIN_CLI } from '../../src/config/constants.ts'; import { FetchMock } from '../utils/mocks/fetch.mock.ts'; diff --git a/test/api/user-setup.client.test.ts b/test/api/user-setup.client.test.ts index 3af26a20..bac41270 100644 --- a/test/api/user-setup.client.test.ts +++ b/test/api/user-setup.client.test.ts @@ -19,21 +19,23 @@ describe('user-setup.client', () => { fetchMock.restore(); }); - it('returns true when user setup is already complete', async () => { - fetchMock.addGraphQL({ eol: { userSetupStatus: true } }); + it('returns isComplete and orgId when user setup is already complete', async () => { + fetchMock.addGraphQL({ eol: { userSetupStatus: { isComplete: true, orgId: 42 } } }); - await expect(getUserSetupStatus()).resolves.toBe(true); + await expect(getUserSetupStatus()).resolves.toEqual({ isComplete: true, orgId: 42 }); }); - it('completes user setup when status is false', async () => { - fetchMock.addGraphQL({ eol: { userSetupStatus: false } }).addGraphQL({ eol: { completeUserSetup: true } }); + it('completes user setup when status is false and returns orgId', async () => { + fetchMock + .addGraphQL({ eol: { userSetupStatus: { isComplete: false } } }) + .addGraphQL({ eol: { completeUserSetup: { isComplete: true, orgId: 42 } } }); - await expect(ensureUserSetup()).resolves.toBeUndefined(); + await expect(ensureUserSetup()).resolves.toBe(42); expect(fetchMock.getCalls()).toHaveLength(2); }); - it('throws when completeUserSetup mutation returns false', async () => { - fetchMock.addGraphQL({ eol: { completeUserSetup: false } }); + it('throws when completeUserSetup mutation returns isComplete false', async () => { + fetchMock.addGraphQL({ eol: { completeUserSetup: { isComplete: false } } }); await expect(completeUserSetup()).rejects.toThrow('Failed to complete user setup'); }); diff --git a/test/commands/auth/provision-ci-token.test.ts b/test/commands/auth/provision-ci-token.test.ts new file mode 100644 index 00000000..bdc7936d --- /dev/null +++ b/test/commands/auth/provision-ci-token.test.ts @@ -0,0 +1,114 @@ +import { type Mock, vi } from 'vitest'; + +vi.mock('../../../src/api/user-setup.client.ts', () => ({ + __esModule: true, + ensureUserSetup: vi.fn(), +})); + +vi.mock('../../../src/service/auth.svc.ts', () => ({ + __esModule: true, + requireAccessToken: vi.fn(), +})); + +vi.mock('../../../src/api/ci-token.client.ts', () => ({ + __esModule: true, + provisionCIToken: vi.fn(), +})); + +vi.mock('../../../src/service/ci-token.svc.ts', () => ({ + __esModule: true, + getCIToken: vi.fn(), + saveCIOrgId: vi.fn(), + saveCIToken: vi.fn(), +})); + +import { provisionCIToken } from '../../../src/api/ci-token.client.ts'; +import { ensureUserSetup } from '../../../src/api/user-setup.client.ts'; +import AuthProvisionCiToken from '../../../src/commands/auth/provision-ci-token.ts'; +import { requireAccessToken } from '../../../src/service/auth.svc.ts'; +import { getCIToken, saveCIOrgId, saveCIToken } from '../../../src/service/ci-token.svc.ts'; + +describe('AuthProvisionCiToken command', () => { + beforeEach(() => { + vi.resetAllMocks(); + (getCIToken as Mock).mockReturnValue(undefined); + (ensureUserSetup as Mock).mockResolvedValue(42); + }); + + it('errors when not logged in', async () => { + (requireAccessToken as Mock).mockRejectedValue(new Error('not logged in')); + const command = new AuthProvisionCiToken([], {} as Record); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: {}, args: {} } as never); + vi.spyOn(command, 'error').mockImplementation((msg) => { + throw new Error(msg as string); + }); + + await expect(command.run()).rejects.toThrow(/logged in/); + expect(provisionCIToken).not.toHaveBeenCalled(); + }); + + it('provisions and saves CI token when logged in', async () => { + (requireAccessToken as Mock).mockResolvedValue('access-token'); + (ensureUserSetup as Mock).mockResolvedValue(123); + (provisionCIToken as Mock).mockResolvedValue({ + refresh_token: 'new-ci-refresh-token', + }); + const command = new AuthProvisionCiToken([], {} as Record); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: {}, args: {} } as never); + const logSpy = vi.spyOn(command, 'log').mockImplementation(() => {}); + + await command.run(); + + expect(requireAccessToken).toHaveBeenCalled(); + expect(ensureUserSetup).toHaveBeenCalled(); + expect(provisionCIToken).toHaveBeenCalledWith({ orgId: 123 }); + expect(saveCIToken).toHaveBeenCalledWith('new-ci-refresh-token'); + expect(saveCIOrgId).toHaveBeenCalledWith(123); + expect(logSpy).toHaveBeenCalledWith('CI token provisioned and saved locally.'); + expect(logSpy).toHaveBeenCalledWith(' HD_ORG_ID=123'); + expect(logSpy).toHaveBeenCalledWith(' HD_AUTH_TOKEN=new-ci-refresh-token'); + }); + + it('provisions for different org when logged in', async () => { + (requireAccessToken as Mock).mockResolvedValue('access-token'); + (ensureUserSetup as Mock).mockResolvedValue(456); + (provisionCIToken as Mock).mockResolvedValue({ + refresh_token: 'new-ci-refresh-token', + }); + const command = new AuthProvisionCiToken([], {} as Record); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: {}, args: {} } as never); + vi.spyOn(command, 'log').mockImplementation(() => {}); + + await command.run(); + + expect(provisionCIToken).toHaveBeenCalledWith({ orgId: 456 }); + expect(saveCIOrgId).toHaveBeenCalledWith(456); + }); + + it('errors when user setup fails', async () => { + (requireAccessToken as Mock).mockResolvedValue('access-token'); + (ensureUserSetup as Mock).mockRejectedValue(new Error('User setup did not return an organization ID')); + const command = new AuthProvisionCiToken([], {} as Record); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: {}, args: {} } as never); + vi.spyOn(command, 'error').mockImplementation((msg) => { + throw new Error(msg as string); + }); + + await expect(command.run()).rejects.toThrow(/User setup failed/); + expect(provisionCIToken).not.toHaveBeenCalled(); + }); + + it('errors when provisioning fails', async () => { + (requireAccessToken as Mock).mockResolvedValue('access-token'); + (ensureUserSetup as Mock).mockResolvedValue(1); + (provisionCIToken as Mock).mockRejectedValue(new Error('Provisioning failed')); + const command = new AuthProvisionCiToken([], {} as Record); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: {}, args: {} } as never); + vi.spyOn(command, 'error').mockImplementation((msg) => { + throw new Error(msg as string); + }); + + await expect(command.run()).rejects.toThrow(/Provisioning failed/); + expect(saveCIToken).not.toHaveBeenCalled(); + }); +}); diff --git a/test/commands/scan/eol.analytics.test.ts b/test/commands/scan/eol.analytics.test.ts index dd2bdb9a..fcf044fd 100644 --- a/test/commands/scan/eol.analytics.test.ts +++ b/test/commands/scan/eol.analytics.test.ts @@ -2,12 +2,14 @@ import type { CdxBom, EolReport } from '@herodevs/eol-shared'; import { ApiError } from '../../../src/api/errors.ts'; import ScanEol from '../../../src/commands/scan/eol.ts'; -const { trackMock, requireAccessTokenForScanMock, submitScanMock, countComponentsByStatusMock } = vi.hoisted(() => ({ - trackMock: vi.fn(), - requireAccessTokenForScanMock: vi.fn(), - submitScanMock: vi.fn(), - countComponentsByStatusMock: vi.fn(), -})); +const { trackMock, requireAccessTokenForScanMock, submitScanMock, countComponentsByStatusMock, ensureUserSetupMock } = + vi.hoisted(() => ({ + trackMock: vi.fn(), + requireAccessTokenForScanMock: vi.fn(), + submitScanMock: vi.fn(), + countComponentsByStatusMock: vi.fn(), + ensureUserSetupMock: vi.fn(), + })); vi.mock('@herodevs/eol-shared', () => ({ trimCdxBom: vi.fn((sbom: unknown) => sbom), @@ -25,6 +27,10 @@ vi.mock('../../../src/api/nes.client.ts', () => ({ submitScan: submitScanMock, })); +vi.mock('../../../src/api/user-setup.client.ts', () => ({ + ensureUserSetup: ensureUserSetupMock, +})); + vi.mock('../../../src/service/display.svc.ts', () => ({ countComponentsByStatus: countComponentsByStatusMock, formatDataPrivacyLink: vi.fn(() => []), @@ -105,6 +111,7 @@ describe('scan:eol analytics timing', () => { beforeEach(() => { vi.clearAllMocks(); requireAccessTokenForScanMock.mockResolvedValue(undefined); + ensureUserSetupMock.mockResolvedValue(1); countComponentsByStatusMock.mockReturnValue({ EOL: 1, EOL_UPCOMING: 0, diff --git a/test/service/analytics.svc.test.ts b/test/service/analytics.svc.test.ts index 3c1021c3..77b6729e 100644 --- a/test/service/analytics.svc.test.ts +++ b/test/service/analytics.svc.test.ts @@ -1,6 +1,10 @@ import sinon from 'sinon'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Hoisted so the mock is registered before any module loads (avoids real machineIdSync on Windows CI) +const mockNodeMachineId = vi.hoisted(() => ({ machineIdSync: vi.fn(() => 'test-machine-id') })); +vi.mock('node-machine-id', () => ({ __esModule: true, default: mockNodeMachineId })); + describe('analytics.svc', () => { const mockAmplitude = { init: sinon.spy(), @@ -10,7 +14,6 @@ describe('analytics.svc', () => { Identify: sinon.stub().returns({}), Types: { LogLevel: { None: 0 } }, }; - const mockNodeMachineId = { machineIdSync: sinon.stub().returns('test-machine-id') }; let originalEnv: typeof process.env; async function setupModule() { @@ -24,10 +27,6 @@ describe('analytics.svc', () => { Identify: mockAmplitude.Identify, Types: mockAmplitude.Types, })); - vi.doMock('node-machine-id', () => ({ - __esModule: true, - default: mockNodeMachineId, - })); vi.doMock('../../src/config/constants.ts', () => ({ __esModule: true, config: { analyticsUrl: 'https://test-analytics.com' }, @@ -46,7 +45,7 @@ describe('analytics.svc', () => { mockAmplitude.setOptOut.resetHistory(); mockAmplitude.identify.resetHistory(); mockAmplitude.track.resetHistory(); - mockNodeMachineId.machineIdSync.resetHistory(); + mockNodeMachineId.machineIdSync.mockClear(); }); describe('initializeAnalytics', () => { @@ -198,8 +197,8 @@ describe('analytics.svc', () => { it('should initialize device_id using NodeMachineId.machineIdSync', async () => { await setupModule(); - expect(mockNodeMachineId.machineIdSync.calledOnce).toBe(true); - expect(mockNodeMachineId.machineIdSync.getCall(0).args[0]).toBe(true); + expect(mockNodeMachineId.machineIdSync).toHaveBeenCalledTimes(1); + expect(mockNodeMachineId.machineIdSync).toHaveBeenCalledWith(true); }); it('should initialize started_at as a Date object', async () => { diff --git a/test/service/auth.svc.test.ts b/test/service/auth.svc.test.ts index 83f49153..ecdd66c1 100644 --- a/test/service/auth.svc.test.ts +++ b/test/service/auth.svc.test.ts @@ -1,5 +1,17 @@ import { type Mock, vi } from 'vitest'; +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + ciTokenFromEnv: undefined as string | undefined, + orgIdFromEnv: undefined as number | undefined, + accessTokenFromEnv: undefined as string | undefined, + }, +})); + +vi.mock('../../src/config/constants.ts', () => ({ + config: mockConfig, +})); + vi.mock('../../src/service/auth-token.svc.ts', () => ({ __esModule: true, getStoredTokens: vi.fn(), @@ -13,6 +25,21 @@ vi.mock('../../src/service/auth-refresh.svc.ts', () => ({ refreshTokens: vi.fn(), })); +vi.mock('../../src/service/ci-token.svc.ts', () => ({ + __esModule: true, + getCIToken: vi.fn(), + getCIOrgId: vi.fn(), + saveCIToken: vi.fn(), +})); + +vi.mock('../../src/service/ci-auth.svc.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + requireCIAccessToken: vi.fn(), + }; +}); + import { AuthError, getAccessToken, @@ -28,10 +55,16 @@ import { isAccessTokenExpired, saveTokens, } from '../../src/service/auth-token.svc.ts'; +import { CITokenError, requireCIAccessToken } from '../../src/service/ci-auth.svc.ts'; +import { getCIToken } from '../../src/service/ci-token.svc.ts'; describe('auth.svc', () => { beforeEach(() => { vi.resetAllMocks(); + (getCIToken as Mock).mockReturnValue(undefined); + mockConfig.ciTokenFromEnv = undefined; + mockConfig.orgIdFromEnv = undefined; + mockConfig.accessTokenFromEnv = undefined; }); it('persists token responses via keyring service', async () => { @@ -86,7 +119,47 @@ describe('auth.svc', () => { }); describe('requireAccessTokenForScan', () => { - it('returns token when access token is valid', async () => { + it('delegates to requireCIAccessToken when CI token present', async () => { + (getCIToken as Mock).mockReturnValue('ci-refresh-token'); + (requireCIAccessToken as Mock).mockResolvedValue('ci-access-token'); + + const token = await requireAccessTokenForScan(); + expect(token).toBe('ci-access-token'); + expect(requireCIAccessToken).toHaveBeenCalled(); + }); + + it('delegates to requireCIAccessToken when HD_ACCESS_TOKEN present', async () => { + mockConfig.accessTokenFromEnv = 'env-access-token'; + (requireCIAccessToken as Mock).mockResolvedValue('env-access-token'); + + const token = await requireAccessTokenForScan(); + expect(token).toBe('env-access-token'); + expect(requireCIAccessToken).toHaveBeenCalled(); + }); + + it('uses CI path before keyring when both CI token and keyring tokens exist', async () => { + (getCIToken as Mock).mockReturnValue('ci-refresh-token'); + (getStoredTokens as Mock).mockResolvedValue({ accessToken: 'keyring-token', refreshToken: 'keyring-refresh' }); + (requireCIAccessToken as Mock).mockResolvedValue('ci-access-token'); + + const token = await requireAccessTokenForScan(); + + expect(token).toBe('ci-access-token'); + expect(requireCIAccessToken).toHaveBeenCalled(); + expect(refreshTokens).not.toHaveBeenCalled(); + }); + + it('propagates CITokenError when requireCIAccessToken throws', async () => { + (getCIToken as Mock).mockReturnValue('ci-refresh-token'); + (requireCIAccessToken as Mock).mockRejectedValue(new CITokenError('Org required', 'CI_ORG_ID_REQUIRED')); + + await expect(requireAccessTokenForScan()).rejects.toMatchObject({ + name: 'CITokenError', + code: 'CI_ORG_ID_REQUIRED', + }); + }); + + it('returns token when access token is valid (login path)', async () => { (getStoredTokens as Mock).mockResolvedValue({ accessToken: 'valid-token' }); (isAccessTokenExpired as Mock).mockReturnValue(false); @@ -109,8 +182,8 @@ describe('auth.svc', () => { it('throws AuthError with NOT_LOGGED_IN when no tokens exist', async () => { (getStoredTokens as Mock).mockResolvedValue(undefined); - await expect(requireAccessTokenForScan()).rejects.toThrow(AuthError); await expect(requireAccessTokenForScan()).rejects.toMatchObject({ + name: 'AuthError', code: 'NOT_LOGGED_IN', message: 'Please log in to perform a scan. To authenticate, run "hd auth login".', }); diff --git a/test/service/ci-auth.svc.test.ts b/test/service/ci-auth.svc.test.ts new file mode 100644 index 00000000..c61992db --- /dev/null +++ b/test/service/ci-auth.svc.test.ts @@ -0,0 +1,137 @@ +import { type Mock, vi } from 'vitest'; + +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + ciTokenFromEnv: undefined as string | undefined, + orgIdFromEnv: undefined as number | undefined, + accessTokenFromEnv: undefined as string | undefined, + }, +})); + +vi.mock('../../src/config/constants.ts', () => ({ + config: mockConfig, +})); + +vi.mock('../../src/service/auth-token.svc.ts', () => ({ + __esModule: true, + isAccessTokenExpired: vi.fn(), +})); + +vi.mock('../../src/api/ci-token.client.ts', () => ({ + __esModule: true, + exchangeCITokenForAccess: vi.fn(), +})); + +vi.mock('../../src/service/ci-token.svc.ts', () => ({ + __esModule: true, + getCIToken: vi.fn(), + getCIOrgId: vi.fn(), + saveCIToken: vi.fn(), +})); + +import { exchangeCITokenForAccess } from '../../src/api/ci-token.client.ts'; +import { isAccessTokenExpired } from '../../src/service/auth-token.svc.ts'; +import { requireCIAccessToken } from '../../src/service/ci-auth.svc.ts'; +import { getCIOrgId, getCIToken, saveCIToken } from '../../src/service/ci-token.svc.ts'; + +describe('ci-auth.svc', () => { + beforeEach(() => { + vi.resetAllMocks(); + (getCIToken as Mock).mockReturnValue(undefined); + (getCIOrgId as Mock).mockReturnValue(undefined); + mockConfig.ciTokenFromEnv = undefined; + mockConfig.orgIdFromEnv = undefined; + mockConfig.accessTokenFromEnv = undefined; + }); + + it('returns HD_ACCESS_TOKEN when set and not expired', async () => { + mockConfig.accessTokenFromEnv = 'env-access-token'; + (isAccessTokenExpired as Mock).mockReturnValue(false); + + const token = await requireCIAccessToken(); + expect(token).toBe('env-access-token'); + expect(exchangeCITokenForAccess).not.toHaveBeenCalled(); + }); + + it('exchanges CI token when HD_ACCESS_TOKEN not set', async () => { + (getCIToken as Mock).mockReturnValue('ci-refresh-token'); + (getCIOrgId as Mock).mockReturnValue(42); + (exchangeCITokenForAccess as Mock).mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + }); + + const token = await requireCIAccessToken(); + expect(token).toBe('new-access-token'); + expect(exchangeCITokenForAccess).toHaveBeenCalledWith({ + refreshToken: 'ci-refresh-token', + orgId: 42, + optionalAccessToken: undefined, + }); + expect(saveCIToken).toHaveBeenCalledWith('new-refresh-token'); + }); + + it('uses orgIdFromEnv when ciTokenFromEnv is set', async () => { + mockConfig.ciTokenFromEnv = 'env-refresh-token'; + mockConfig.orgIdFromEnv = 123; + (getCIToken as Mock).mockReturnValue('env-refresh-token'); + (exchangeCITokenForAccess as Mock).mockResolvedValue({ + accessToken: 'access', + refreshToken: 'refresh', + }); + + await requireCIAccessToken(); + expect(exchangeCITokenForAccess).toHaveBeenCalledWith({ + refreshToken: 'env-refresh-token', + orgId: 123, + optionalAccessToken: undefined, + }); + expect(getCIOrgId).not.toHaveBeenCalled(); + }); + + it('does not saveCIToken when token comes from env', async () => { + mockConfig.ciTokenFromEnv = 'env-refresh-token'; + mockConfig.orgIdFromEnv = 123; + (getCIToken as Mock).mockReturnValue('env-refresh-token'); + (exchangeCITokenForAccess as Mock).mockResolvedValue({ + accessToken: 'access', + refreshToken: 'rotated-refresh', + }); + + await requireCIAccessToken(); + expect(saveCIToken).not.toHaveBeenCalled(); + }); + + it('throws when CI token is missing', async () => { + (getCIToken as Mock).mockReturnValue(undefined); + + await expect(requireCIAccessToken()).rejects.toMatchObject({ + name: 'CITokenError', + code: 'CI_TOKEN_INVALID', + }); + expect(exchangeCITokenForAccess).not.toHaveBeenCalled(); + }); + + it('throws when orgId cannot be resolved', async () => { + (getCIToken as Mock).mockReturnValue('ci-refresh-token'); + (getCIOrgId as Mock).mockReturnValue(undefined); + + await expect(requireCIAccessToken()).rejects.toMatchObject({ + name: 'CITokenError', + code: 'CI_ORG_ID_REQUIRED', + message: expect.stringContaining('HD_ORG_ID'), + }); + expect(exchangeCITokenForAccess).not.toHaveBeenCalled(); + }); + + it('throws when exchange fails', async () => { + (getCIToken as Mock).mockReturnValue('ci-refresh-token'); + (getCIOrgId as Mock).mockReturnValue(42); + (exchangeCITokenForAccess as Mock).mockRejectedValue(new Error('exchange failed')); + + await expect(requireCIAccessToken()).rejects.toMatchObject({ + name: 'CITokenError', + code: 'CI_TOKEN_REFRESH_FAILED', + }); + }); +}); diff --git a/test/service/ci-token.svc.test.ts b/test/service/ci-token.svc.test.ts new file mode 100644 index 00000000..5337f725 --- /dev/null +++ b/test/service/ci-token.svc.test.ts @@ -0,0 +1,150 @@ +import { vi } from 'vitest'; + +const store = new Map(); + +const mockCiTokenFromEnv = { value: undefined as string | undefined }; + +vi.mock('../../src/config/constants.ts', () => ({ + config: { + get ciTokenFromEnv(): string | undefined { + const v = mockCiTokenFromEnv.value; + return v === undefined || v === '' ? undefined : v; + }, + }, +})); + +vi.mock('conf', () => ({ + default: class MockConf { + get(key: string): unknown { + return store.get(key); + } + + set(key: string, value: unknown): void { + store.set(key, value); + } + + delete(key: string): void { + store.delete(key); + } + + has(key: string): boolean { + return store.has(key); + } + }, +})); + +import { + clearCIOrgId, + clearCIToken, + decryptToken, + encryptToken, + getCIOrgId, + getCIToken, + getCITokenFromStorage, + saveCIOrgId, + saveCIToken, +} from '../../src/service/ci-token.svc.ts'; + +describe('ci-token.svc', () => { + beforeEach(() => { + store.clear(); + mockCiTokenFromEnv.value = undefined; + }); + + describe('encryptToken / decryptToken', () => { + it('round-trips a plaintext token', () => { + const plain = 'refresh-token-abc'; + const encrypted = encryptToken(plain); + expect(encrypted).not.toBe(plain); + expect(decryptToken(encrypted)).toBe(plain); + }); + + it('produces different ciphertext each time due to random IV', () => { + const plain = 'same-token'; + const a = encryptToken(plain); + const b = encryptToken(plain); + expect(a).not.toBe(b); + expect(decryptToken(a)).toBe(plain); + expect(decryptToken(b)).toBe(plain); + }); + + it('throws on invalid encrypted format', () => { + expect(() => decryptToken('not-valid-base64!!!')).toThrow(/Invalid encrypted token format/); + }); + }); + + describe('getCITokenFromStorage / saveCIToken / clearCIToken', () => { + it('returns undefined when storage is empty', () => { + expect(getCITokenFromStorage()).toBeUndefined(); + }); + + it('saves and retrieves token from storage (encrypted)', () => { + saveCIToken('my-refresh-token'); + expect(getCITokenFromStorage()).toBe('my-refresh-token'); + }); + + it('clearCIToken removes stored token', () => { + saveCIToken('token'); + clearCIToken(); + expect(getCITokenFromStorage()).toBeUndefined(); + }); + }); + + describe('getCIOrgId / saveCIOrgId / clearCIOrgId', () => { + it('returns undefined when orgId is not stored', () => { + expect(getCIOrgId()).toBeUndefined(); + }); + + it('saves and retrieves orgId', () => { + saveCIOrgId(123); + expect(getCIOrgId()).toBe(123); + }); + + it('returns undefined for invalid stored values', () => { + store.set('ciOrgId', 0); + expect(getCIOrgId()).toBeUndefined(); + store.set('ciOrgId', -1); + expect(getCIOrgId()).toBeUndefined(); + store.set('ciOrgId', 'not-a-number'); + expect(getCIOrgId()).toBeUndefined(); + }); + + it('clearCIOrgId removes stored orgId', () => { + saveCIOrgId(42); + clearCIOrgId(); + expect(getCIOrgId()).toBeUndefined(); + }); + + it('clearCIToken also removes stored orgId', () => { + saveCIToken('token'); + saveCIOrgId(99); + clearCIToken(); + expect(getCITokenFromStorage()).toBeUndefined(); + expect(getCIOrgId()).toBeUndefined(); + }); + }); + + describe('getCIToken', () => { + it('returns config.ciTokenFromEnv when set', () => { + mockCiTokenFromEnv.value = 'env-token'; + expect(getCIToken()).toBe('env-token'); + }); + + it('prefers config.ciTokenFromEnv over storage', () => { + mockCiTokenFromEnv.value = 'env-token'; + saveCIToken('storage-token'); + expect(getCIToken()).toBe('env-token'); + }); + + it('falls back to storage when config.ciTokenFromEnv is unset', () => { + mockCiTokenFromEnv.value = undefined; + saveCIToken('storage-token'); + expect(getCIToken()).toBe('storage-token'); + }); + + it('returns undefined when neither config.ciTokenFromEnv nor storage has token', () => { + mockCiTokenFromEnv.value = undefined; + expect(getCIToken()).toBeUndefined(); + }); + }); +});