diff --git a/biome.json b/biome.json index 65b5e406..9c4ff943 100644 --- a/biome.json +++ b/biome.json @@ -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, diff --git a/src/api/gql-operations.ts b/src/api/gql-operations.ts index eb51d634..28882b3f 100644 --- a/src/api/gql-operations.ts +++ b/src/api/gql-operations.ts @@ -37,3 +37,19 @@ query GetEolReport($input: GetEolReportInput) { } } `; + +export const userSetupStatusQuery = gql` +query Eol { + eol { + userSetupStatus + } +} +`; + +export const completeUserSetupMutation = gql` +mutation Eol { + eol { + completeUserSetup + } +} +`; diff --git a/src/api/graphql-errors.ts b/src/api/graphql-errors.ts new file mode 100644 index 00000000..2b746429 --- /dev/null +++ b/src/api/graphql-errors.ts @@ -0,0 +1,33 @@ +import type { GraphQLFormattedError } from 'graphql'; + +export type GraphQLErrorResult = { + error?: unknown; + errors?: ReadonlyArray; +}; + +export function getGraphQLErrors(result: GraphQLErrorResult): ReadonlyArray | 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 }).errors; + if (errors?.length) { + return errors; + } + } + + if ('graphQLErrors' in error) { + const errors = (error as { graphQLErrors?: ReadonlyArray }).graphQLErrors; + if (errors?.length) { + return errors; + } + } + + return; +} diff --git a/src/api/nes.client.ts b/src/api/nes.client.ts index 64191fce..ff12312c 100644 --- a/src/api/nes.client.ts +++ b/src/api/nes.client.ts @@ -13,21 +13,22 @@ 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; - 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; -}; + return fetch(input, { ...init, headers }); + }; function extractErrorCode(errors: ReadonlyArray): ApiErrorCode | undefined { const code = (errors[0]?.extensions as { code?: string })?.code; @@ -35,7 +36,7 @@ function extractErrorCode(errors: ReadonlyArray): ApiErro return code; } -export const createApollo = (uri: string) => +export const createApollo = (uri: string, tokenProvider: TokenProvider = requireAccessTokenForScan) => new ApolloClient({ cache: new InMemoryCache(), defaultOptions: { @@ -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'}`, }, @@ -59,8 +60,8 @@ export const SbomScanner = (client: ReturnType) => { 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); @@ -104,7 +105,7 @@ export const SbomScanner = (client: ReturnType) => { 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) { diff --git a/src/api/user-setup.client.ts b/src/api/user-setup.client.ts new file mode 100644 index 00000000..60e80137 --- /dev/null +++ b/src/api/user-setup.client.ts @@ -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): ApiErrorCode | undefined { + const code = (errors[0]?.extensions as { code?: string })?.code; + if (!code || !isApiErrorCode(code)) return; + return code; +} + +export async function getUserSetupStatus(): Promise { + const client = createApollo(getGraphqlUrl(), requireAccessToken); + 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 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 { + const client = createApollo(getGraphqlUrl(), requireAccessToken); + 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 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 { + 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, + }); +} diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index ec76ce01..f97f5862 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -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'; @@ -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)}`); } } diff --git a/src/config/constants.ts b/src/config/constants.ts index 06dc8944..bdc1e088 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -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; @@ -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, }; diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 00000000..77a6312f --- /dev/null +++ b/src/utils/retry.ts @@ -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(operation: string, fn: () => Promise, options: RetryOptions): Promise { + 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.'); +} diff --git a/test/api/user-setup.client.test.ts b/test/api/user-setup.client.test.ts new file mode 100644 index 00000000..213b41ce --- /dev/null +++ b/test/api/user-setup.client.test.ts @@ -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); + }); +}); diff --git a/test/commands/auth/login.test.ts b/test/commands/auth/login.test.ts index 8ea5b003..021e9b4f 100644 --- a/test/commands/auth/login.test.ts +++ b/test/commands/auth/login.test.ts @@ -1,5 +1,6 @@ import type { Config } from '@oclif/core'; import { type Mock, type MockedFunction, vi } from 'vitest'; +import { ensureUserSetup } from '../../../src/api/user-setup.client.ts'; import AuthLogin from '../../../src/commands/auth/login.ts'; import { persistTokenResponse } from '../../../src/service/auth.svc.ts'; import { openInBrowser } from '../../../src/utils/open-in-browser.ts'; @@ -69,6 +70,23 @@ vi.mock('http', () => ({ }, })); +vi.mock('../../../src/config/constants.ts', () => ({ + __esModule: true, + config: { + get enableAuth() { + return process.env.ENABLE_AUTH === 'true'; + }, + get enableUserSetup() { + return process.env.ENABLE_USER_SETUP === 'true'; + }, + }, +})); + +vi.mock('../../../src/api/user-setup.client.ts', () => ({ + __esModule: true, + ensureUserSetup: vi.fn(), +})); + vi.mock('../../../src/utils/open-in-browser.ts', () => ({ __esModule: true, openInBrowser: vi.fn(), @@ -92,6 +110,7 @@ vi.mock('../../../src/service/auth.svc.ts', () => ({ const openMock = vi.mocked(openInBrowser) as MockedFunction; const persistTokenResponseMock = vi.mocked(persistTokenResponse); +const ensureUserSetupMock = vi.mocked(ensureUserSetup); const flushAsync = () => new Promise((resolve) => setImmediate(resolve)); @@ -128,11 +147,14 @@ describe('AuthLogin', () => { questionMock.mockImplementation((_q, cb) => cb('')); closeMock.mockClear(); openMock.mockResolvedValue(undefined); + ensureUserSetupMock.mockResolvedValue(undefined); + delete process.env.ENABLE_USER_SETUP; }); afterEach(() => { vi.clearAllMocks(); delete process.env.OAUTH_CALLBACK_PORT; + delete process.env.ENABLE_USER_SETUP; serverInstances.length = 0; persistTokenResponseMock.mockClear(); }); @@ -366,5 +388,36 @@ describe('AuthLogin', () => { expect(persistTokenResponseMock).toHaveBeenCalledWith(tokenResponse); }); + + it('runs user setup after login', async () => { + process.env.ENABLE_USER_SETUP = 'true'; + const command = createCommand(6001); + const tokenResponse = { access_token: 'access', refresh_token: 'refresh' }; + const commandWithInternals = command as unknown as { + startServerAndAwaitCode: (...args: unknown[]) => Promise; + exchangeCodeForToken: (...args: unknown[]) => Promise; + }; + vi.spyOn(commandWithInternals, 'startServerAndAwaitCode').mockResolvedValue('code-123'); + vi.spyOn(commandWithInternals, 'exchangeCodeForToken').mockResolvedValue(tokenResponse); + + await command.run(); + + expect(ensureUserSetupMock).toHaveBeenCalledTimes(1); + }); + + it('fails login when user setup fails', async () => { + process.env.ENABLE_USER_SETUP = 'true'; + ensureUserSetupMock.mockRejectedValueOnce(new Error('setup failed')); + const command = createCommand(6002); + const tokenResponse = { access_token: 'access', refresh_token: 'refresh' }; + const commandWithInternals = command as unknown as { + startServerAndAwaitCode: (...args: unknown[]) => Promise; + exchangeCodeForToken: (...args: unknown[]) => Promise; + }; + vi.spyOn(commandWithInternals, 'startServerAndAwaitCode').mockResolvedValue('code-123'); + vi.spyOn(commandWithInternals, 'exchangeCodeForToken').mockResolvedValue(tokenResponse); + + await expect(command.run()).rejects.toThrow('User setup failed'); + }); }); });