Skip to content

Commit 21047b5

Browse files
feat: add user setup flow behind feature flag (#469)
1 parent e71bfe7 commit 21047b5

10 files changed

Lines changed: 324 additions & 17 deletions

File tree

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
33
"assist": { "actions": { "source": { "organizeImports": "on" } } },
44
"linter": {
55
"enabled": true,

src/api/gql-operations.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,19 @@ query GetEolReport($input: GetEolReportInput) {
3737
}
3838
}
3939
`;
40+
41+
export const userSetupStatusQuery = gql`
42+
query Eol {
43+
eol {
44+
userSetupStatus
45+
}
46+
}
47+
`;
48+
49+
export const completeUserSetupMutation = gql`
50+
mutation Eol {
51+
eol {
52+
completeUserSetup
53+
}
54+
}
55+
`;

src/api/graphql-errors.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { GraphQLFormattedError } from 'graphql';
2+
3+
export type GraphQLErrorResult = {
4+
error?: unknown;
5+
errors?: ReadonlyArray<GraphQLFormattedError>;
6+
};
7+
8+
export function getGraphQLErrors(result: GraphQLErrorResult): ReadonlyArray<GraphQLFormattedError> | undefined {
9+
if (result.errors?.length) {
10+
return result.errors;
11+
}
12+
13+
const error = result.error;
14+
if (!error || typeof error !== 'object') {
15+
return;
16+
}
17+
18+
if ('errors' in error) {
19+
const errors = (error as { errors?: ReadonlyArray<GraphQLFormattedError> }).errors;
20+
if (errors?.length) {
21+
return errors;
22+
}
23+
}
24+
25+
if ('graphQLErrors' in error) {
26+
const errors = (error as { graphQLErrors?: ReadonlyArray<GraphQLFormattedError> }).graphQLErrors;
27+
if (errors?.length) {
28+
return errors;
29+
}
30+
}
31+
32+
return;
33+
}

src/api/nes.client.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,30 @@ import { debugLogger } from '../service/log.svc.ts';
1313
import { stripTypename } from '../utils/strip-typename.ts';
1414
import { ApiError, type ApiErrorCode, isApiErrorCode } from './errors.ts';
1515
import { createReportMutation, getEolReportQuery } from './gql-operations.ts';
16+
import { getGraphQLErrors } from './graphql-errors.ts';
1617

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

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

25-
return fetch(input, { ...init, headers });
26-
};
25+
if (config.enableAuth) {
26+
const token = await tokenProvider();
27+
headers.set('Authorization', `Bearer ${token}`);
28+
}
2729

28-
type GraphQLExecutionResult = {
29-
errors?: ReadonlyArray<GraphQLFormattedError>;
30-
};
30+
return fetch(input, { ...init, headers });
31+
};
3132

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

38-
export const createApollo = (uri: string) =>
39+
export const createApollo = (uri: string, tokenProvider: TokenProvider = requireAccessTokenForScan) =>
3940
new ApolloClient({
4041
cache: new InMemoryCache(),
4142
defaultOptions: {
@@ -44,7 +45,7 @@ export const createApollo = (uri: string) =>
4445
},
4546
link: new HttpLink({
4647
uri,
47-
fetch: createAuthorizedFetch(),
48+
fetch: createAuthorizedFetch(tokenProvider),
4849
headers: {
4950
'User-Agent': `hdcli/${process.env.npm_package_version ?? 'unknown'}`,
5051
},
@@ -59,8 +60,8 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
5960
variables: { input },
6061
});
6162

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

106107
for (const response of batchResponses) {
107-
const queryErrors = (response as GraphQLExecutionResult | undefined)?.errors;
108+
const queryErrors = getGraphQLErrors(response);
108109
if (response?.error || queryErrors?.length || !response.data?.eol) {
109110
debugLogger('Error in getReport query response: %o', response?.error ?? queryErrors ?? response);
110111
if (queryErrors?.length) {

src/api/user-setup.client.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { GraphQLFormattedError } from 'graphql';
2+
import { config } from '../config/constants.ts';
3+
import { requireAccessToken } from '../service/auth.svc.ts';
4+
import { debugLogger } from '../service/log.svc.ts';
5+
import { withRetries } from '../utils/retry.ts';
6+
import { ApiError, type ApiErrorCode, isApiErrorCode } from './errors.ts';
7+
import { completeUserSetupMutation, userSetupStatusQuery } from './gql-operations.ts';
8+
import { getGraphQLErrors } from './graphql-errors.ts';
9+
import { createApollo } from './nes.client.ts';
10+
11+
const USER_SETUP_MAX_ATTEMPTS = 3;
12+
const USER_SETUP_RETRY_DELAY_MS = 500;
13+
14+
type UserSetupStatusResponse = {
15+
eol?: {
16+
userSetupStatus?: boolean;
17+
};
18+
};
19+
20+
type CompleteUserSetupResponse = {
21+
eol?: {
22+
completeUserSetup?: boolean;
23+
};
24+
};
25+
26+
const getGraphqlUrl = () => `${config.graphqlHost}${config.graphqlPath}`;
27+
28+
function extractErrorCode(errors: ReadonlyArray<GraphQLFormattedError>): ApiErrorCode | undefined {
29+
const code = (errors[0]?.extensions as { code?: string })?.code;
30+
if (!code || !isApiErrorCode(code)) return;
31+
return code;
32+
}
33+
34+
export async function getUserSetupStatus(): Promise<boolean> {
35+
const client = createApollo(getGraphqlUrl(), requireAccessToken);
36+
const res = await client.query<UserSetupStatusResponse>({ query: userSetupStatusQuery });
37+
38+
const errors = getGraphQLErrors(res);
39+
if (res?.error || errors?.length) {
40+
debugLogger('Error returned from userSetupStatus query: %o', res.error || errors);
41+
if (errors?.length) {
42+
const code = extractErrorCode(errors);
43+
if (code) {
44+
throw new ApiError(errors[0].message, code);
45+
}
46+
}
47+
throw new Error('Failed to check user setup status');
48+
}
49+
50+
const isComplete = res.data?.eol?.userSetupStatus;
51+
if (typeof isComplete !== 'boolean') {
52+
debugLogger('Unexpected userSetupStatus query response: %o', res.data);
53+
throw new Error('Failed to check user setup status');
54+
}
55+
56+
return isComplete;
57+
}
58+
59+
export async function completeUserSetup(): Promise<boolean> {
60+
const client = createApollo(getGraphqlUrl(), requireAccessToken);
61+
const res = await client.mutate<CompleteUserSetupResponse>({ mutation: completeUserSetupMutation });
62+
63+
const errors = getGraphQLErrors(res);
64+
if (res?.error || errors?.length) {
65+
debugLogger('Error returned from completeUserSetup mutation: %o', res.error || errors);
66+
if (errors?.length) {
67+
const code = extractErrorCode(errors);
68+
if (code) {
69+
throw new ApiError(errors[0].message, code);
70+
}
71+
}
72+
throw new Error('Failed to complete user setup');
73+
}
74+
75+
const success = res.data?.eol?.completeUserSetup;
76+
if (!success) {
77+
debugLogger('completeUserSetup mutation returned unsuccessful response: %o', res.data);
78+
throw new Error('Failed to complete user setup');
79+
}
80+
81+
return success;
82+
}
83+
84+
export async function ensureUserSetup(): Promise<void> {
85+
const isComplete = await withRetries('user-setup-status', () => getUserSetupStatus(), {
86+
attempts: USER_SETUP_MAX_ATTEMPTS,
87+
baseDelayMs: USER_SETUP_RETRY_DELAY_MS,
88+
});
89+
if (isComplete) {
90+
return;
91+
}
92+
93+
await withRetries('user-setup-complete', () => completeUserSetup(), {
94+
attempts: USER_SETUP_MAX_ATTEMPTS,
95+
baseDelayMs: USER_SETUP_RETRY_DELAY_MS,
96+
});
97+
}

src/commands/auth/login.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import http from 'node:http';
33
import { createInterface } from 'node:readline';
44
import { URL } from 'node:url';
55
import { Command } from '@oclif/core';
6+
import { ensureUserSetup } from '../../api/user-setup.client.ts';
7+
import { config } from '../../config/constants.ts';
68
import { persistTokenResponse } from '../../service/auth.svc.ts';
79
import { getClientId, getRealmUrl } from '../../service/auth-config.svc.ts';
10+
import { getErrorMessage } from '../../service/log.svc.ts';
811
import type { TokenResponse } from '../../types/auth.ts';
912
import { openInBrowser } from '../../utils/open-in-browser.ts';
1013

@@ -42,6 +45,16 @@ export default class AuthLogin extends Command {
4245
await persistTokenResponse(token);
4346
} catch (error) {
4447
this.warn(`Failed to store tokens securely: ${error instanceof Error ? error.message : error}`);
48+
return;
49+
}
50+
51+
if (!config.enableUserSetup) {
52+
return;
53+
}
54+
try {
55+
await ensureUserSetup();
56+
} catch (error) {
57+
this.error(`User setup failed. ${getErrorMessage(error)}`);
4558
}
4659
}
4760

src/config/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd';
1111
export const DEFAULT_DATE_COMMIT_FORMAT = 'MM/dd/yyyy, h:mm:ss a';
1212
export const DEFAULT_DATE_COMMIT_MONTH_FORMAT = 'MMMM yyyy';
1313
export const ENABLE_AUTH = false;
14+
export const ENABLE_USER_SETUP = false;
1415

1516
const toBoolean = (value: string | undefined): boolean | undefined => {
1617
if (value === 'true') return true;
@@ -40,6 +41,7 @@ export const config = {
4041
graphqlPath: process.env.GRAPHQL_PATH || GRAPHQL_PATH,
4142
analyticsUrl: process.env.ANALYTICS_URL || ANALYTICS_URL,
4243
enableAuth: toBoolean(process.env.ENABLE_AUTH) ?? ENABLE_AUTH,
44+
enableUserSetup: toBoolean(process.env.ENABLE_USER_SETUP) ?? ENABLE_USER_SETUP,
4345
concurrentPageRequests,
4446
pageSize,
4547
};

src/utils/retry.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { debugLogger } from '../service/log.svc.ts';
2+
3+
export type RetryOptions = {
4+
attempts: number;
5+
baseDelayMs: number;
6+
onRetry?: (info: { attempt: number; delayMs: number; error: unknown }) => void;
7+
finalErrorMessage?: string;
8+
};
9+
10+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
11+
12+
export async function withRetries<T>(operation: string, fn: () => Promise<T>, options: RetryOptions): Promise<T> {
13+
const { attempts, baseDelayMs, onRetry, finalErrorMessage } = options;
14+
15+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
16+
try {
17+
return await fn();
18+
} catch (error) {
19+
if (attempt === attempts) {
20+
break;
21+
}
22+
23+
const delayMs = baseDelayMs * attempt;
24+
if (onRetry) {
25+
onRetry({ attempt, delayMs, error });
26+
} else {
27+
debugLogger('Retry (%s) attempt %d/%d after %dms: %o', operation, attempt, attempts, delayMs, error);
28+
}
29+
await sleep(delayMs);
30+
}
31+
}
32+
33+
throw new Error(finalErrorMessage ?? 'Please contact your administrator.');
34+
}

test/api/user-setup.client.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { ApiError } from '../../src/api/errors.ts';
2+
import { completeUserSetup, ensureUserSetup, getUserSetupStatus } from '../../src/api/user-setup.client.ts';
3+
import { FetchMock } from '../utils/mocks/fetch.mock.ts';
4+
5+
describe('user-setup.client', () => {
6+
let fetchMock: FetchMock;
7+
8+
beforeEach(() => {
9+
fetchMock = new FetchMock();
10+
});
11+
12+
afterEach(() => {
13+
fetchMock.restore();
14+
});
15+
16+
it('returns true when user setup is already complete', async () => {
17+
fetchMock.addGraphQL({ eol: { userSetupStatus: true } });
18+
19+
await expect(getUserSetupStatus()).resolves.toBe(true);
20+
});
21+
22+
it('completes user setup when status is false', async () => {
23+
fetchMock.addGraphQL({ eol: { userSetupStatus: false } }).addGraphQL({ eol: { completeUserSetup: true } });
24+
25+
await expect(ensureUserSetup()).resolves.toBeUndefined();
26+
expect(fetchMock.getCalls()).toHaveLength(2);
27+
});
28+
29+
it('throws when completeUserSetup mutation returns false', async () => {
30+
fetchMock.addGraphQL({ eol: { completeUserSetup: false } });
31+
32+
await expect(completeUserSetup()).rejects.toThrow('Failed to complete user setup');
33+
});
34+
35+
it('throws ApiError when GraphQL errors include an auth code', async () => {
36+
fetchMock.addGraphQL({ eol: { userSetupStatus: null } }, [
37+
{ message: 'Not authenticated', extensions: { code: 'UNAUTHENTICATED' } },
38+
]);
39+
40+
await expect(getUserSetupStatus()).rejects.toBeInstanceOf(ApiError);
41+
});
42+
43+
it('retries and asks to contact admin after repeated server errors', async () => {
44+
fetchMock
45+
.addGraphQL({ eol: { userSetupStatus: null } }, [
46+
{ message: 'Internal server error', extensions: { code: 'INTERNAL_SERVER_ERROR' } },
47+
])
48+
.addGraphQL({ eol: { userSetupStatus: null } }, [
49+
{ message: 'Internal server error', extensions: { code: 'INTERNAL_SERVER_ERROR' } },
50+
])
51+
.addGraphQL({ eol: { userSetupStatus: null } }, [
52+
{ message: 'Internal server error', extensions: { code: 'INTERNAL_SERVER_ERROR' } },
53+
]);
54+
55+
await expect(ensureUserSetup()).rejects.toThrow('Please contact your administrator.');
56+
expect(fetchMock.getCalls()).toHaveLength(3);
57+
});
58+
});

0 commit comments

Comments
 (0)