diff --git a/src/api/apollo.client.ts b/src/api/apollo.client.ts index 917cbc4a..d22ea57d 100644 --- a/src/api/apollo.client.ts +++ b/src/api/apollo.client.ts @@ -1,5 +1,6 @@ import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client/core'; import { requireAccessTokenForScan } from '../service/auth.svc.ts'; +import { ApiError, PAYLOAD_TOO_LARGE_ERROR_CODE } from './errors.ts'; export type TokenProvider = (forceRefresh?: boolean) => Promise; @@ -42,7 +43,15 @@ const createAuthorizedFetch = const refreshed = await tokenProvider(true); const retryHeaders = new Headers(init?.headers); retryHeaders.set('Authorization', `Bearer ${refreshed}`); - return fetch(input, { ...init, headers: retryHeaders }); + const retryResponse = await fetch(input, { ...init, headers: retryHeaders }); + if (retryResponse.status === 413) { + throw new ApiError('Payload too large', PAYLOAD_TOO_LARGE_ERROR_CODE); + } + return retryResponse; + } + + if (response.status === 413) { + throw new ApiError('Payload too large', PAYLOAD_TOO_LARGE_ERROR_CODE); } return response; diff --git a/src/api/errors.ts b/src/api/errors.ts index 528efb4e..6fd416af 100644 --- a/src/api/errors.ts +++ b/src/api/errors.ts @@ -1,4 +1,16 @@ -const API_ERROR_CODES = ['SESSION_EXPIRED', 'INVALID_TOKEN', 'UNAUTHENTICATED', 'FORBIDDEN'] as const; +export const SESSION_EXPIRED_ERROR_CODE = 'SESSION_EXPIRED'; +export const INVALID_TOKEN_ERROR_CODE = 'INVALID_TOKEN'; +export const UNAUTHENTICATED_ERROR_CODE = 'UNAUTHENTICATED'; +export const FORBIDDEN_ERROR_CODE = 'FORBIDDEN'; +export const PAYLOAD_TOO_LARGE_ERROR_CODE = 'PAYLOAD_TOO_LARGE'; + +const API_ERROR_CODES = [ + SESSION_EXPIRED_ERROR_CODE, + INVALID_TOKEN_ERROR_CODE, + UNAUTHENTICATED_ERROR_CODE, + FORBIDDEN_ERROR_CODE, + PAYLOAD_TOO_LARGE_ERROR_CODE, +] as const; export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; const VALID_API_ERROR_CODES = new Set(API_ERROR_CODES); diff --git a/src/api/nes.client.ts b/src/api/nes.client.ts index 4771ac07..089927b6 100644 --- a/src/api/nes.client.ts +++ b/src/api/nes.client.ts @@ -31,6 +31,9 @@ export const SbomScanner = (client: ReturnType) => { const errors = getGraphQLErrors(res); if (res?.error || errors?.length) { debugLogger('Error returned from createReport mutation: %o', res.error || errors); + if (res?.error instanceof ApiError) { + throw res.error; + } if (errors?.length) { const code = extractErrorCode(errors); if (code) { @@ -76,6 +79,9 @@ export const SbomScanner = (client: ReturnType) => { const queryErrors = getGraphQLErrors(response); if (response?.error || queryErrors?.length || !response.data?.eol) { debugLogger('Error in getReport query response: %o', response?.error ?? queryErrors ?? response); + if (response?.error instanceof ApiError) { + throw response.error; + } if (queryErrors?.length) { const code = extractErrorCode(queryErrors); if (code) { diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 76881c29..747fb6d8 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -2,7 +2,7 @@ import type { CdxBom, EolReport } from '@herodevs/eol-shared'; import { trimCdxBom } from '@herodevs/eol-shared'; import { Command, Flags } from '@oclif/core'; import ora from 'ora'; -import { ApiError } from '../../api/errors.ts'; +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'; @@ -233,6 +233,10 @@ export default class ScanEol extends Command { number_of_packages: numberOfPackages, })); + if (error.code === PAYLOAD_TOO_LARGE_ERROR_CODE) { + this.error(this.getPayloadTooLargeMessage(Boolean(flags.file))); + } + const message = AUTH_ERROR_MESSAGES[error.code] ?? error.message?.trim(); this.error(message); } @@ -253,6 +257,15 @@ export default class ScanEol extends Command { return (performance.now() - scanStartTime) / 1000; } + private getPayloadTooLargeMessage(hasUserProvidedSbom: boolean): string { + const USER_PROVIDED_SBOM_TOO_LARGE_MESSAGE = + 'File exceeds the 10MB limit. Try providing a smaller or partial SBOM.'; + const GENERATED_SBOM_TOO_LARGE_MESSAGE = + 'Generated SBOM exceeds the 10MB upload limit. Try scanning a smaller scope (e.g. a single project or subdirectory).'; + + return hasUserProvidedSbom ? USER_PROVIDED_SBOM_TOO_LARGE_MESSAGE : GENERATED_SBOM_TOO_LARGE_MESSAGE; + } + private saveReport(report: EolReport, dir: string, outputPath?: string): string { try { return saveArtifactToFile(dir, { kind: 'report', payload: report, outputPath }); diff --git a/src/service/cdx.svc.ts b/src/service/cdx.svc.ts index c81e5845..3ec7172c 100644 --- a/src/service/cdx.svc.ts +++ b/src/service/cdx.svc.ts @@ -76,18 +76,22 @@ type CreateSbomDependencies = { postProcess: typeof postProcess; }; +type BomGenerationResult = { + bomJson: CdxBom; +}; + export function createSbomFactory({ createBom: createBomDependency = createBom, postProcess: postProcessDependency = postProcess, }: Partial = {}) { return async function createSbom(directory: string): Promise { - const sbom: any = await createBomDependency(directory, SBOM_DEFAULT__OPTIONS); + const sbom = (await createBomDependency(directory, SBOM_DEFAULT__OPTIONS)) as BomGenerationResult | undefined; if (!sbom) { throw new Error('SBOM not generated'); } - const postProcessedSbom: any = postProcessDependency(sbom, SBOM_DEFAULT__OPTIONS); + const postProcessedSbom = postProcessDependency(sbom, SBOM_DEFAULT__OPTIONS) as BomGenerationResult | undefined; if (!postProcessedSbom) { throw new Error('SBOM not generated'); diff --git a/test/api/nes.client.test.ts b/test/api/nes.client.test.ts index 7a2adf30..2f072c85 100644 --- a/test/api/nes.client.test.ts +++ b/test/api/nes.client.test.ts @@ -3,8 +3,10 @@ import { vi } from 'vitest'; vi.mock('../../src/config/constants.ts', async (importOriginal) => importOriginal()); +import { ApiError, PAYLOAD_TOO_LARGE_ERROR_CODE } from '../../src/api/errors.ts'; import { submitScan } from '../../src/api/nes.client.ts'; import { SCAN_ORIGIN_AUTOMATED, SCAN_ORIGIN_CLI } from '../../src/config/constants.ts'; +import { requireAccessTokenForScan } from '../../src/service/auth.svc.ts'; import { FetchMock } from '../utils/mocks/fetch.mock.ts'; vi.mock('../../src/service/auth.svc.ts', () => ({ @@ -125,6 +127,54 @@ describe('nes.client', () => { await expect(submitScan(input)).rejects.toThrow(/Failed to create EOL report/); }); + it('throws ApiError with PAYLOAD_TOO_LARGE code when server returns 413', async () => { + fetchMock.push({ + headers: { get: () => 'text/plain' }, + status: 413, + async text() { + return ''; + }, + } as unknown as Response); + + const input: CreateEolReportInput = { + sbom: { bomFormat: 'CycloneDX', components: [], specVersion: '1.4', version: 1 }, + }; + const error = await submitScan(input).catch((e) => e); + expect(error).toBeInstanceOf(ApiError); + expect(error.code).toBe(PAYLOAD_TOO_LARGE_ERROR_CODE); + }); + + it('throws ApiError with PAYLOAD_TOO_LARGE when retry after 401 returns 413', async () => { + vi.mocked(requireAccessTokenForScan).mockReset(); + vi.mocked(requireAccessTokenForScan) + .mockResolvedValueOnce('expired-token') + .mockResolvedValueOnce('refreshed-token'); + + fetchMock.push({ + headers: { get: () => 'text/plain' }, + status: 401, + async text() { + return ''; + }, + } as unknown as Response); + fetchMock.push({ + headers: { get: () => 'text/plain' }, + status: 413, + async text() { + return ''; + }, + } as unknown as Response); + + const input: CreateEolReportInput = { + sbom: { bomFormat: 'CycloneDX', components: [], specVersion: '1.4', version: 1 }, + }; + const error = await submitScan(input).catch((e) => e); + + expect(error).toBeInstanceOf(ApiError); + expect(error.code).toBe(PAYLOAD_TOO_LARGE_ERROR_CODE); + expect(vi.mocked(requireAccessTokenForScan).mock.calls).toEqual([[], [true]]); + }); + describe('scanOrigin', () => { it('passes scanOrigin to createReport mutation when provided', async () => { const components = [{ purl: 'pkg:npm/test@1.0.0', metadata: { isEol: false } }]; diff --git a/test/commands/scan/eol.analytics.test.ts b/test/commands/scan/eol.analytics.test.ts index 94a1a656..ab53eb62 100644 --- a/test/commands/scan/eol.analytics.test.ts +++ b/test/commands/scan/eol.analytics.test.ts @@ -1,5 +1,6 @@ import type { CdxBom, EolReport } from '@herodevs/eol-shared'; -import { ApiError } from '../../../src/api/errors.ts'; +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'; const { @@ -86,7 +87,7 @@ type ScanCommandInternals = { }; function createCommand(): ScanCommandInternals { - return new ScanEol([], {} as Record) as unknown as ScanCommandInternals; + return new ScanEol([], {} as Config) as unknown as ScanCommandInternals; } function getTrackProperties(eventName: string): Record { @@ -152,7 +153,7 @@ describe('scan:eol analytics timing', () => { }); it('tracks scan_load_time on ApiError scan failures', async () => { - submitScanMock.mockRejectedValue(new ApiError('forbidden', 'FORBIDDEN')); + submitScanMock.mockRejectedValue(new ApiError('forbidden', FORBIDDEN_ERROR_CODE)); const command = createCommand(); vi.spyOn(command, 'parse').mockResolvedValue({ @@ -165,12 +166,52 @@ describe('scan:eol analytics timing', () => { await expect(command.scanSbom(sampleSbom)).rejects.toThrow('You do not have permission to perform this action.'); const properties = getTrackProperties('CLI EOL Scan Failed'); - expect(properties.scan_failure_reason).toBe('FORBIDDEN'); + expect(properties.scan_failure_reason).toBe(FORBIDDEN_ERROR_CODE); expect(properties.scan_load_time).toEqual(expect.any(Number)); expect(properties.scan_load_time as number).toBeGreaterThanOrEqual(0); expect(properties.number_of_packages).toBe(1); }); + it('shows the user-supplied SBOM message on PAYLOAD_TOO_LARGE failures', async () => { + submitScanMock.mockRejectedValue(new ApiError('Payload too large', PAYLOAD_TOO_LARGE_ERROR_CODE)); + + const command = createCommand(); + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: { + automated: false, + saveTrimmedSbom: false, + file: '/tmp/sample.sbom.json', + }, + }); + vi.spyOn(command, 'error').mockImplementation((message: string) => { + throw new Error(message); + }); + + await expect(command.scanSbom(sampleSbom)).rejects.toThrow( + 'File exceeds the 10MB limit. Try providing a smaller or partial SBOM.', + ); + }); + + it('shows the generated SBOM message on PAYLOAD_TOO_LARGE failures', async () => { + submitScanMock.mockRejectedValue(new ApiError('Payload too large', PAYLOAD_TOO_LARGE_ERROR_CODE)); + + const command = createCommand(); + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: { + automated: false, + saveTrimmedSbom: false, + dir: process.cwd(), + }, + }); + vi.spyOn(command, 'error').mockImplementation((message: string) => { + throw new Error(message); + }); + + await expect(command.scanSbom(sampleSbom)).rejects.toThrow( + 'Generated SBOM exceeds the 10MB upload limit. Try scanning a smaller scope (e.g. a single project or subdirectory).', + ); + }); + it('keeps scan_load_time on successful completion events', async () => { const command = createCommand();