Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 49 additions & 29 deletions src/api/ci-token.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export interface ProvisionCITokenOptions {
previousToken?: string | null;
}

export class CITokenCommunicationError extends Error {
constructor(message: string) {
super(message);
this.name = 'CITokenCommunicationError';
}
}

type GetOrgAccessTokensResponse = {
iamV2?: {
access?: {
Expand All @@ -39,29 +46,45 @@ function extractErrorCode(errors: ReadonlyArray<GraphQLFormattedError>): ApiErro
return code;
}

function classifyCiTokenError(
errors: ReadonlyArray<GraphQLFormattedError> | undefined,
error: unknown,
fallbackMessage: string,
): Error {
if (errors?.length) {
const message = errors[0].message ?? fallbackMessage;
const code = extractErrorCode(errors);
if (code) {
return new ApiError(message, code);
}

return new CITokenCommunicationError(message);
}

const message = error instanceof Error ? error.message : error ? String(error) : fallbackMessage;
return new CITokenCommunicationError(message || fallbackMessage);
}

async function getOrgAccessTokens(
input: IamAccessOrgTokensInput,
): Promise<{ accessToken: string; refreshToken: string }> {
const client = createApollo(graphqlUrl, requireAccessToken);
const res = await client.mutate<GetOrgAccessTokensResponse, { input: IamAccessOrgTokensInput }>({
mutation: getOrgAccessTokensMutation,
variables: {
input,
},
});
let res: Awaited<ReturnType<typeof client.mutate<GetOrgAccessTokensResponse, { input: IamAccessOrgTokensInput }>>>;
try {
res = await client.mutate<GetOrgAccessTokensResponse, { input: IamAccessOrgTokensInput }>({
mutation: getOrgAccessTokensMutation,
variables: {
input,
},
});
} catch (error) {
throw classifyCiTokenError(undefined, error, 'CI token provisioning failed');
}

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');
throw classifyCiTokenError(errors, res?.error, 'CI token provisioning failed');
}

const tokens = res.data?.iamV2?.access?.getOrgAccessTokens;
Expand All @@ -88,28 +111,25 @@ async function callGetOrgAccessTokensInternal(
tokenProvider: TokenProvider,
): Promise<{ accessToken: string; refreshToken: string }> {
const client = createApollo(graphqlUrl, tokenProvider);
const res = await client.mutate<GetOrgAccessTokensResponse, { input: IamAccessOrgTokensInput }>({
mutation: getOrgAccessTokensMutation,
variables: { input },
});
let res: Awaited<ReturnType<typeof client.mutate<GetOrgAccessTokensResponse, { input: IamAccessOrgTokensInput }>>>;
try {
res = await client.mutate<GetOrgAccessTokensResponse, { input: IamAccessOrgTokensInput }>({
mutation: getOrgAccessTokensMutation,
variables: { input },
});
} catch (error) {
throw classifyCiTokenError(undefined, error, 'CI token refresh failed');
}

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');
throw classifyCiTokenError(errors, res?.error, 'CI token refresh failed');
}

const tokens = res.data?.iamV2?.access?.getOrgAccessTokens;
if (!tokens?.accessToken) {
throw new Error('getOrgAccessTokens response missing accessToken');
throw new CITokenCommunicationError('getOrgAccessTokens response missing accessToken');
}

return {
Expand Down
12 changes: 10 additions & 2 deletions src/commands/scan/eol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ApiError, PAYLOAD_TOO_LARGE_ERROR_CODE } from '../../api/errors.ts';
import { submitScan } from '../../api/nes.client.ts';
import { config, filenamePrefix, SCAN_ORIGIN_AUTOMATED, SCAN_ORIGIN_CLI } from '../../config/constants.ts';
import { track } from '../../service/analytics.svc.ts';
import { AUTH_ERROR_MESSAGES, getTokenForScanWithSource } from '../../service/auth.svc.ts';
import { AUTH_ERROR_MESSAGES, getTokenForScanWithSource, type TokenSource } from '../../service/auth.svc.ts';
import { createSbom } from '../../service/cdx.svc.ts';
import {
countComponentsByStatus,
Expand Down Expand Up @@ -87,7 +87,7 @@ export default class ScanEol extends Command {
public async run(): Promise<EolReport | undefined> {
const { flags } = await this.parse(ScanEol);

const { source } = await getTokenForScanWithSource();
const { source } = await this.getTokenSourceForScan();
if (source === 'ci') {
this.log('CI credentials found');
this.log('Using CI credentials');
Expand Down Expand Up @@ -257,6 +257,14 @@ export default class ScanEol extends Command {
return (performance.now() - scanStartTime) / 1000;
}

private async getTokenSourceForScan(): Promise<{ token: string; source: TokenSource }> {
try {
return await getTokenForScanWithSource();
} catch (error) {
this.error(getErrorMessage(error));
}
}

private getPayloadTooLargeMessage(hasUserProvidedSbom: boolean): string {
const USER_PROVIDED_SBOM_TOO_LARGE_MESSAGE =
'File exceeds the 10MB limit. Try providing a smaller or partial SBOM.';
Expand Down
15 changes: 13 additions & 2 deletions src/service/ci-auth.svc.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { exchangeCITokenForAccess } from '../api/ci-token.client.ts';
import { CITokenCommunicationError, exchangeCITokenForAccess } from '../api/ci-token.client.ts';
import { ApiError } from '../api/errors.ts';
import { config } from '../config/constants.ts';
import { 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';
export type CITokenErrorCode =
| 'CI_TOKEN_INVALID'
| 'CI_TOKEN_REFRESH_FAILED'
| 'CI_TOKEN_COMMUNICATION_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 CITOKEN_COMMUNICATION_ERROR_MESSAGE =
'There was an error communicating with the HeroDevs server while refreshing the CI token. Please verify server connectivity/configuration and try again.';

export class CITokenError extends Error {
readonly code: CITokenErrorCode;
Expand Down Expand Up @@ -34,6 +41,10 @@ export async function requireCIAccessToken(): Promise<string> {
return result.accessToken;
} catch (error) {
debugLogger('CI token refresh failed: %O', error);
if (error instanceof CITokenCommunicationError || !(error instanceof Error) || !(error instanceof ApiError)) {
throw new CITokenError(CITOKEN_COMMUNICATION_ERROR_MESSAGE, 'CI_TOKEN_COMMUNICATION_FAILED');
}

throw new CITokenError(CITOKEN_ERROR_MESSAGE, 'CI_TOKEN_REFRESH_FAILED');
}
}
94 changes: 87 additions & 7 deletions test/api/ci-token.client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ vi.mock('../../src/config/constants.ts', async (importOriginal) => {
});

import {
CITokenCommunicationError,
exchangeCITokenForAccess,
getOrgAccessTokensUnauthenticated,
provisionCIToken,
} from '../../src/api/ci-token.client.ts';
import { ApiError } from '../../src/api/errors.ts';
import { FetchMock } from '../utils/mocks/fetch.mock.ts';

const mockHeaders = {
Expand Down Expand Up @@ -48,8 +50,8 @@ function mockGraphQLResponse(data: {
} as unknown as Response;
}

function mockGraphQLErrorResponse(message: string) {
const payload = { errors: [{ message }] };
function mockGraphQLErrorResponse(message: string, extensions?: Record<string, unknown>) {
const payload = { errors: [{ message, extensions }] };
return {
ok: true,
status: 200,
Expand Down Expand Up @@ -220,18 +222,96 @@ describe('ci-token.client', () => {
});
});

it('throws when GraphQL returns errors', async () => {
it('treats GraphQL errors without codes as communication errors', async () => {
fetchMock.push(mockGraphQLErrorResponse('Invalid refresh token'));

await expect(getOrgAccessTokensUnauthenticated({ previousToken: 'bad-token' })).rejects.toThrow(
/Invalid refresh token/,
);
const promise = getOrgAccessTokensUnauthenticated({ previousToken: 'bad-token' });
await expect(promise).rejects.toBeInstanceOf(CITokenCommunicationError);
await expect(promise).rejects.toThrow(/Invalid refresh token/);
});

it('throws when response is not ok', async () => {
fetchMock.push(mockErrorResponse(500, 'Internal Server Error'));

await expect(getOrgAccessTokensUnauthenticated({ previousToken: 'token' })).rejects.toThrow(/500/);
const promise = getOrgAccessTokensUnauthenticated({ previousToken: 'token' });
await expect(promise).rejects.toBeInstanceOf(CITokenCommunicationError);
await expect(promise).rejects.toThrow(/500/);
});

it('throws communication error when GraphQL data is null', async () => {
fetchMock.push({
ok: true,
status: 200,
headers: mockHeaders,
async json() {
return { data: null };
},
async text() {
return JSON.stringify({ data: null });
},
} as unknown as Response);

const promise = getOrgAccessTokensUnauthenticated({ previousToken: 'token' });
await expect(promise).rejects.toBeInstanceOf(CITokenCommunicationError);
await expect(promise).rejects.toThrow(/missing accessToken/);
});

it('throws communication error when getOrgAccessTokens is null', async () => {
fetchMock.push(
mockGraphQLResponse({
iamV2: {
access: {
getOrgAccessTokens: undefined,
},
},
}),
);

const promise = getOrgAccessTokensUnauthenticated({ previousToken: 'token' });
await expect(promise).rejects.toBeInstanceOf(CITokenCommunicationError);
await expect(promise).rejects.toThrow(/missing accessToken/);
});

it('throws communication error when accessToken is missing', async () => {
fetchMock.push(
mockGraphQLResponse({
iamV2: {
access: {
getOrgAccessTokens: {
refreshToken: 'refresh-only',
},
},
},
}),
);

const promise = getOrgAccessTokensUnauthenticated({ previousToken: 'token' });
await expect(promise).rejects.toBeInstanceOf(CITokenCommunicationError);
await expect(promise).rejects.toThrow(/missing accessToken/);
});

it('throws communication error on fetch failure', async () => {
fetchMock.push(Promise.reject(new Error('connect ETIMEDOUT gateway.test')));

const promise = getOrgAccessTokensUnauthenticated({ previousToken: 'token' });
await expect(promise).rejects.toBeInstanceOf(CITokenCommunicationError);
await expect(promise).rejects.toThrow(/ETIMEDOUT/);
});

it('treats explicit auth GraphQL codes as ApiError', async () => {
fetchMock.push(mockGraphQLErrorResponse('Not authorized', { code: 'UNAUTHENTICATED' }));

const promise = getOrgAccessTokensUnauthenticated({ previousToken: 'token' });
await expect(promise).rejects.toBeInstanceOf(ApiError);
await expect(promise).rejects.toThrow(/Not authorized/);
});

it('treats resolver-like GraphQL errors as communication errors', async () => {
fetchMock.push(mockGraphQLErrorResponse("Cannot read properties of undefined (reading 'accessToken')"));

const promise = getOrgAccessTokensUnauthenticated({ previousToken: 'token' });
await expect(promise).rejects.toBeInstanceOf(CITokenCommunicationError);
await expect(promise).rejects.toThrow(/accessToken/);
});
});

Expand Down
35 changes: 35 additions & 0 deletions test/commands/scan/eol.analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CdxBom, EolReport } from '@herodevs/eol-shared';
import type { Config } from '@oclif/core';
import { ApiError, FORBIDDEN_ERROR_CODE, PAYLOAD_TOO_LARGE_ERROR_CODE } from '../../../src/api/errors.ts';
import ScanEol from '../../../src/commands/scan/eol.ts';
import { CITokenError } from '../../../src/service/auth.svc.ts';

const {
trackMock,
Expand Down Expand Up @@ -238,4 +239,38 @@ describe('scan:eol analytics timing', () => {
expect(properties.scan_load_time).toEqual(expect.any(Number));
expect(properties.scan_load_time as number).toBeGreaterThanOrEqual(0);
});

it('formats CI token errors without exposing the internal error class name', async () => {
getTokenForScanWithSourceMock.mockRejectedValue(
new CITokenError(
'There was an error communicating with the HeroDevs server while refreshing the CI token. Please verify server connectivity/configuration and try again.',
'CI_TOKEN_COMMUNICATION_FAILED',
),
);

const command = createCommand();
vi.spyOn(command, 'parse').mockResolvedValue({
flags: {
file: '/tmp/sample.sbom.json',
save: false,
output: undefined,
saveSbom: false,
sbomOutput: undefined,
saveTrimmedSbom: false,
hideReportUrl: false,
automated: false,
},
});
const errorSpy = vi.spyOn(command, 'error').mockImplementation((input: string | Error) => {
const message = input instanceof Error ? input.message : input;
throw new Error(message);
});

await expect(command.run()).rejects.toThrow(
'There was an error communicating with the HeroDevs server while refreshing the CI token. Please verify server connectivity/configuration and try again.',
);
expect(errorSpy).toHaveBeenCalledWith(
'There was an error communicating with the HeroDevs server while refreshing the CI token. Please verify server connectivity/configuration and try again.',
);
});
});
15 changes: 15 additions & 0 deletions test/service/auth.svc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,21 @@ describe('auth.svc', () => {
});
});

it('propagates communication CITokenError when CI refresh cannot reach the server', async () => {
(getCIToken as Mock).mockReturnValue('ci-refresh-token');
(requireCIAccessToken as Mock).mockRejectedValue(
new CITokenError(
'There was an error communicating with the HeroDevs server while refreshing the CI token. Please verify server connectivity/configuration and try again.',
'CI_TOKEN_COMMUNICATION_FAILED',
),
);

await expect(requireAccessTokenForScan()).rejects.toMatchObject({
name: 'CITokenError',
code: 'CI_TOKEN_COMMUNICATION_FAILED',
});
});

it('returns token when access token is valid (login path)', async () => {
(getStoredTokens as Mock).mockResolvedValue({ accessToken: 'valid-token' });
(isAccessTokenExpired as Mock).mockReturnValue(false);
Expand Down
Loading