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
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
Expand Down
16 changes: 16 additions & 0 deletions src/api/gql-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,19 @@ query GetEolReport($input: GetEolReportInput) {
}
}
`;

export const userSetupStatusQuery = gql`
query Eol {
eol {
userSetupStatus
}
}
`;

export const completeUserSetupMutation = gql`
mutation Eol {
eol {
completeUserSetup
}
}
`;
33 changes: 33 additions & 0 deletions src/api/graphql-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { GraphQLFormattedError } from 'graphql';

export type GraphQLErrorResult = {
error?: unknown;
errors?: ReadonlyArray<GraphQLFormattedError>;
};

export function getGraphQLErrors(result: GraphQLErrorResult): ReadonlyArray<GraphQLFormattedError> | undefined {
if (result.errors?.length) {
return result.errors;
}

const error = result.error;
if (!error || typeof error !== 'object') {
return;
}

if ('errors' in error) {
const errors = (error as { errors?: ReadonlyArray<GraphQLFormattedError> }).errors;
if (errors?.length) {
return errors;
}
}

if ('graphQLErrors' in error) {
const errors = (error as { graphQLErrors?: ReadonlyArray<GraphQLFormattedError> }).graphQLErrors;
if (errors?.length) {
return errors;
}
}

return;
}
33 changes: 17 additions & 16 deletions src/api/nes.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,30 @@ import { debugLogger } from '../service/log.svc.ts';
import { stripTypename } from '../utils/strip-typename.ts';
import { ApiError, type ApiErrorCode, isApiErrorCode } from './errors.ts';
import { createReportMutation, getEolReportQuery } from './gql-operations.ts';
import { getGraphQLErrors } from './graphql-errors.ts';

const createAuthorizedFetch = (): typeof fetch => async (input, init) => {
const headers = new Headers(init?.headers);
type TokenProvider = () => Promise<string>;

if (config.enableAuth) {
const token = await requireAccessTokenForScan();
headers.set('Authorization', `Bearer ${token}`);
}
const createAuthorizedFetch =
(tokenProvider: TokenProvider): typeof fetch =>
async (input, init) => {
const headers = new Headers(init?.headers);

return fetch(input, { ...init, headers });
};
if (config.enableAuth) {
const token = await tokenProvider();
headers.set('Authorization', `Bearer ${token}`);
}

type GraphQLExecutionResult = {
errors?: ReadonlyArray<GraphQLFormattedError>;
};
return fetch(input, { ...init, headers });
};

function extractErrorCode(errors: ReadonlyArray<GraphQLFormattedError>): ApiErrorCode | undefined {
const code = (errors[0]?.extensions as { code?: string })?.code;
if (!code || !isApiErrorCode(code)) return;
return code;
}

export const createApollo = (uri: string) =>
export const createApollo = (uri: string, tokenProvider: TokenProvider = requireAccessTokenForScan) =>
new ApolloClient({
cache: new InMemoryCache(),
defaultOptions: {
Expand All @@ -44,7 +45,7 @@ export const createApollo = (uri: string) =>
},
link: new HttpLink({
uri,
fetch: createAuthorizedFetch(),
fetch: createAuthorizedFetch(tokenProvider),
headers: {
'User-Agent': `hdcli/${process.env.npm_package_version ?? 'unknown'}`,
},
Expand All @@ -59,8 +60,8 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
variables: { input },
});

if (res?.error || (res as GraphQLExecutionResult)?.errors) {
const errors = (res as GraphQLExecutionResult | undefined)?.errors;
const errors = getGraphQLErrors(res);
if (res?.error || errors?.length) {
debugLogger('Error returned from createReport mutation: %o', res.error || errors);
if (errors?.length) {
const code = extractErrorCode(errors);
Expand Down Expand Up @@ -104,7 +105,7 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
batchResponses = await Promise.all(batch);

for (const response of batchResponses) {
const queryErrors = (response as GraphQLExecutionResult | undefined)?.errors;
const queryErrors = getGraphQLErrors(response);
if (response?.error || queryErrors?.length || !response.data?.eol) {
debugLogger('Error in getReport query response: %o', response?.error ?? queryErrors ?? response);
if (queryErrors?.length) {
Expand Down
97 changes: 97 additions & 0 deletions src/api/user-setup.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { GraphQLFormattedError } from 'graphql';
import { config } from '../config/constants.ts';
import { requireAccessToken } from '../service/auth.svc.ts';
import { debugLogger } from '../service/log.svc.ts';
import { withRetries } from '../utils/retry.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;

type UserSetupStatusResponse = {
eol?: {
userSetupStatus?: boolean;
};
};

type CompleteUserSetupResponse = {
eol?: {
completeUserSetup?: boolean;
};
};

const getGraphqlUrl = () => `${config.graphqlHost}${config.graphqlPath}`;

function extractErrorCode(errors: ReadonlyArray<GraphQLFormattedError>): ApiErrorCode | undefined {
const code = (errors[0]?.extensions as { code?: string })?.code;
if (!code || !isApiErrorCode(code)) return;
return code;
}

export async function getUserSetupStatus(): Promise<boolean> {
const client = createApollo(getGraphqlUrl(), requireAccessToken);
const res = await client.query<UserSetupStatusResponse>({ 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 code = extractErrorCode(errors);
if (code) {
throw new ApiError(errors[0].message, code);
}
}
throw new Error('Failed to check user setup status');
}

const isComplete = res.data?.eol?.userSetupStatus;
if (typeof isComplete !== 'boolean') {
debugLogger('Unexpected userSetupStatus query response: %o', res.data);
throw new Error('Failed to check user setup status');
}

return isComplete;
}

export async function completeUserSetup(): Promise<boolean> {
const client = createApollo(getGraphqlUrl(), requireAccessToken);
const res = await client.mutate<CompleteUserSetupResponse>({ 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 code = extractErrorCode(errors);
if (code) {
throw new ApiError(errors[0].message, code);
}
}
throw new Error('Failed to complete user setup');
}

const success = res.data?.eol?.completeUserSetup;
if (!success) {
debugLogger('completeUserSetup mutation returned unsuccessful response: %o', res.data);
throw new Error('Failed to complete user setup');
}

return success;
}

export async function ensureUserSetup(): Promise<void> {
const isComplete = await withRetries('user-setup-status', () => getUserSetupStatus(), {
attempts: USER_SETUP_MAX_ATTEMPTS,
baseDelayMs: USER_SETUP_RETRY_DELAY_MS,
});
if (isComplete) {
return;
}

await withRetries('user-setup-complete', () => completeUserSetup(), {
attempts: USER_SETUP_MAX_ATTEMPTS,
baseDelayMs: USER_SETUP_RETRY_DELAY_MS,
});
}
13 changes: 13 additions & 0 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import http from 'node:http';
import { createInterface } from 'node:readline';
import { URL } from 'node:url';
import { Command } from '@oclif/core';
import { ensureUserSetup } from '../../api/user-setup.client.ts';
import { config } from '../../config/constants.ts';
import { persistTokenResponse } from '../../service/auth.svc.ts';
import { getClientId, getRealmUrl } from '../../service/auth-config.svc.ts';
import { getErrorMessage } from '../../service/log.svc.ts';
import type { TokenResponse } from '../../types/auth.ts';
import { openInBrowser } from '../../utils/open-in-browser.ts';

Expand Down Expand Up @@ -42,6 +45,16 @@ export default class AuthLogin extends Command {
await persistTokenResponse(token);
} catch (error) {
this.warn(`Failed to store tokens securely: ${error instanceof Error ? error.message : error}`);
return;
}

if (!config.enableUserSetup) {
return;
}
try {
await ensureUserSetup();
} catch (error) {
this.error(`User setup failed. ${getErrorMessage(error)}`);
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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;

const toBoolean = (value: string | undefined): boolean | undefined => {
if (value === 'true') return true;
Expand Down Expand Up @@ -40,6 +41,7 @@ export const config = {
graphqlPath: process.env.GRAPHQL_PATH || GRAPHQL_PATH,
analyticsUrl: process.env.ANALYTICS_URL || ANALYTICS_URL,
enableAuth: toBoolean(process.env.ENABLE_AUTH) ?? ENABLE_AUTH,
enableUserSetup: toBoolean(process.env.ENABLE_USER_SETUP) ?? ENABLE_USER_SETUP,
concurrentPageRequests,
pageSize,
};
Expand Down
34 changes: 34 additions & 0 deletions src/utils/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { debugLogger } from '../service/log.svc.ts';

export type RetryOptions = {
attempts: number;
baseDelayMs: number;
onRetry?: (info: { attempt: number; delayMs: number; error: unknown }) => void;
finalErrorMessage?: string;
};

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function withRetries<T>(operation: string, fn: () => Promise<T>, options: RetryOptions): Promise<T> {
const { attempts, baseDelayMs, onRetry, finalErrorMessage } = options;

for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await fn();
} catch (error) {
if (attempt === attempts) {
break;
}

const delayMs = baseDelayMs * attempt;
if (onRetry) {
onRetry({ attempt, delayMs, error });
} else {
debugLogger('Retry (%s) attempt %d/%d after %dms: %o', operation, attempt, attempts, delayMs, error);
}
await sleep(delayMs);
}
}

throw new Error(finalErrorMessage ?? 'Please contact your administrator.');
}
58 changes: 58 additions & 0 deletions test/api/user-setup.client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ApiError } from '../../src/api/errors.ts';
import { completeUserSetup, ensureUserSetup, getUserSetupStatus } from '../../src/api/user-setup.client.ts';
import { FetchMock } from '../utils/mocks/fetch.mock.ts';

describe('user-setup.client', () => {
let fetchMock: FetchMock;

beforeEach(() => {
fetchMock = new FetchMock();
});

afterEach(() => {
fetchMock.restore();
});

it('returns true when user setup is already complete', async () => {
fetchMock.addGraphQL({ eol: { userSetupStatus: true } });

await expect(getUserSetupStatus()).resolves.toBe(true);
});

it('completes user setup when status is false', async () => {
fetchMock.addGraphQL({ eol: { userSetupStatus: false } }).addGraphQL({ eol: { completeUserSetup: true } });

await expect(ensureUserSetup()).resolves.toBeUndefined();
expect(fetchMock.getCalls()).toHaveLength(2);
});

it('throws when completeUserSetup mutation returns false', async () => {
fetchMock.addGraphQL({ eol: { completeUserSetup: false } });

await expect(completeUserSetup()).rejects.toThrow('Failed to complete user setup');
});

it('throws ApiError when GraphQL errors include an auth code', async () => {
fetchMock.addGraphQL({ eol: { userSetupStatus: null } }, [
{ message: 'Not authenticated', extensions: { code: 'UNAUTHENTICATED' } },
]);

await expect(getUserSetupStatus()).rejects.toBeInstanceOf(ApiError);
});

it('retries and asks to contact admin after repeated server errors', async () => {
fetchMock
.addGraphQL({ eol: { userSetupStatus: null } }, [
{ message: 'Internal server error', extensions: { code: 'INTERNAL_SERVER_ERROR' } },
])
.addGraphQL({ eol: { userSetupStatus: null } }, [
{ message: 'Internal server error', extensions: { code: 'INTERNAL_SERVER_ERROR' } },
])
.addGraphQL({ eol: { userSetupStatus: null } }, [
{ message: 'Internal server error', extensions: { code: 'INTERNAL_SERVER_ERROR' } },
]);

await expect(ensureUserSetup()).rejects.toThrow('Please contact your administrator.');
expect(fetchMock.getCalls()).toHaveLength(3);
});
});
Loading