From 2542f0ac566c7e51f827915838103b9fcd30df52 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Jul 2026 17:37:01 +0000 Subject: [PATCH] feat(jira-wizard): add API-token-with-scopes auth-type selector (MNG-1744) --- .../web/jira-credentials-auth-type.test.ts | 111 +++++++++++++++++ tests/unit/web/pm-wizard-hooks.test.ts | 64 ++++++++++ tests/unit/web/pm-wizard-state.test.ts | 44 +++++++ .../projects/pm-providers/jira/auth.ts | 4 + .../projects/pm-providers/jira/hooks.ts | 6 + .../projects/pm-providers/jira/state.ts | 27 ++++ .../projects/pm-providers/jira/wizard.ts | 116 +++++++++++++++--- .../components/projects/pm-wizard-state.ts | 2 + 8 files changed, 358 insertions(+), 16 deletions(-) create mode 100644 tests/unit/web/jira-credentials-auth-type.test.ts diff --git a/tests/unit/web/jira-credentials-auth-type.test.ts b/tests/unit/web/jira-credentials-auth-type.test.ts new file mode 100644 index 00000000..9569c8d1 --- /dev/null +++ b/tests/unit/web/jira-credentials-auth-type.test.ts @@ -0,0 +1,111 @@ +/** + * JIRA credentials step — API-token-with-scopes selector (MNG-1744). + * + * The JiraCredentialsAdapter (first step of the JIRA wizard) renders a + * basic/scoped auth-type selector above the credential inputs. The choice + * drives host routing (site URL for `basic`, api.atlassian.com gateway for + * `scoped`) and is threaded into the verify credential bag as `auth_type`. + * + * These tests render the adapter to static markup (SSR-safe shadcn + * primitives) and assert the selector + helper text, and verify the + * segmented control is wired to `SET_JIRA_AUTH_TYPE`. + */ + +import { createElement, isValidElement, type ReactElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { jiraProviderWizard } from '../../../web/src/components/projects/pm-providers/jira/wizard.js'; +import type { ProviderWizardStepProps } from '../../../web/src/components/projects/pm-providers/types.js'; +import type { + WizardAction, + WizardState, +} from '../../../web/src/components/projects/pm-wizard-state.js'; +import { createInitialState } from '../../../web/src/components/projects/pm-wizard-state.js'; + +const CredentialsComponent = jiraProviderWizard.steps.find((s) => s.id === 'jira-credentials') + ?.Component as (props: ProviderWizardStepProps) => ReactElement; + +function jiraState(overrides: Partial = {}): WizardState { + return { ...createInitialState(), provider: 'jira', ...overrides }; +} + +function renderAdapter( + state: WizardState, + dispatch: (action: WizardAction) => void = () => {}, +): string { + return renderToStaticMarkup(createElement(CredentialsComponent, { state, dispatch })); +} + +/** Recursively collect every React element in a rendered tree. */ +function collectElements(node: unknown, out: ReactElement[] = []): ReactElement[] { + if (Array.isArray(node)) { + for (const child of node) collectElements(child, out); + return out; + } + if (isValidElement(node)) { + out.push(node); + const props = node.props as { children?: unknown }; + if (props?.children !== undefined) collectElements(props.children, out); + } + return out; +} + +describe('JiraCredentialsAdapter auth-type selector (MNG-1744)', () => { + it('renders both basic and scoped options', () => { + const html = renderAdapter(jiraState()); + expect(html).toContain('data-auth-type-selector="jira"'); + expect(html).toContain('data-auth-type-option="basic"'); + expect(html).toContain('data-auth-type-option="scoped"'); + expect(html).toContain('API token'); + expect(html).toContain('API token with scopes'); + }); + + it('still renders the base_url + email + api_token credential inputs', () => { + const html = renderAdapter(jiraState()); + expect(html).toContain('data-role="base_url"'); + expect(html).toContain('data-role="email"'); + expect(html).toContain('data-role="api_token"'); + }); + + it('reflects the basic default in the selected option + helper text', () => { + const html = renderAdapter(jiraState()); + expect(html).toContain('data-auth-type-hint="basic"'); + // basic helper text mentions the classic site URL routing. + expect(html).toContain('site URL'); + // basic is selected, scoped is not. + expect(html).toMatch( + /data-auth-type-option="basic"[^>]*data-selected="true"|data-selected="true"[^>]*data-auth-type-option="basic"/, + ); + expect(html).toMatch( + /data-auth-type-option="scoped"[^>]*data-selected="false"|data-selected="false"[^>]*data-auth-type-option="scoped"/, + ); + }); + + it('reflects the scoped selection in the helper text (api.atlassian.com gateway)', () => { + const html = renderAdapter(jiraState({ jiraAuthType: 'scoped' })); + expect(html).toContain('data-auth-type-hint="scoped"'); + expect(html).toContain('api.atlassian.com'); + expect(html).toMatch( + /data-auth-type-option="scoped"[^>]*data-selected="true"|data-selected="true"[^>]*data-auth-type-option="scoped"/, + ); + }); + + it('dispatches SET_JIRA_AUTH_TYPE with the clicked option value', () => { + const dispatch = vi.fn(); + const tree = CredentialsComponent({ state: jiraState(), dispatch }); + const elements = collectElements(tree); + + const scoped = elements.find( + (el) => (el.props as Record)['data-auth-type-option'] === 'scoped', + ); + expect(scoped).toBeDefined(); + (scoped?.props as { onClick: () => void }).onClick(); + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_JIRA_AUTH_TYPE', value: 'scoped' }); + + const basic = elements.find( + (el) => (el.props as Record)['data-auth-type-option'] === 'basic', + ); + (basic?.props as { onClick: () => void }).onClick(); + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_JIRA_AUTH_TYPE', value: 'basic' }); + }); +}); diff --git a/tests/unit/web/pm-wizard-hooks.test.ts b/tests/unit/web/pm-wizard-hooks.test.ts index 0d4f8013..755b21f6 100644 --- a/tests/unit/web/pm-wizard-hooks.test.ts +++ b/tests/unit/web/pm-wizard-hooks.test.ts @@ -83,6 +83,29 @@ describe('provider credential metadata', () => { email: 'user@example.com', api_token: 'jira-tok', base_url: 'https://example.atlassian.net', + // jiraAuthType defaults to 'basic' and is threaded as a non-secret + // connection setting (MNG-1744). + auth_type: 'basic', + }, + }); + // Scoped auth_type flows through the same metadata-driven verify bag. + expect( + buildProviderAuthArgFromMetadata( + jiraState({ + jiraEmail: 'user@example.com', + jiraApiToken: 'jira-tok', + jiraBaseUrl: 'https://example.atlassian.net', + jiraAuthType: 'scoped', + }), + 'proj-j', + jiraProviderWizard.auth, + ), + ).toEqual({ + credentials: { + email: 'user@example.com', + api_token: 'jira-tok', + base_url: 'https://example.atlassian.net', + auth_type: 'scoped', }, }); expect( @@ -137,7 +160,12 @@ describe('provider credential metadata', () => { 'email', 'api_token', 'base_url', + 'auth_type', ]); + // auth_type maps to the jiraAuthType wizard-state field (MNG-1744). + expect( + jiraProviderWizard.auth.rawCredentials.find((c) => c.role === 'auth_type')?.stateField, + ).toBe('jiraAuthType'); expect(jiraProviderWizard.credentialPersistence.map((c) => c.envVarKey)).not.toContain( 'JIRA_BASE_URL', ); @@ -266,6 +294,8 @@ describe('metadata-driven verification request', () => { email: 'user@example.com', api_token: 'jira-token', base_url: 'https://example.atlassian.net', + // auth_type is auto-included in the verify bag (defaults to 'basic'). + auth_type: 'basic', }, }); expect( @@ -282,6 +312,31 @@ describe('metadata-driven verification request', () => { }); }); + it('threads scoped auth_type into the JIRA verify (currentUser) credential bag (MNG-1744)', () => { + expect( + buildCurrentUserDiscoveryRequest( + jiraState({ + jiraEmail: 'user@example.com', + jiraApiToken: 'jira-token', + jiraBaseUrl: 'https://example.atlassian.net', + jiraAuthType: 'scoped', + }), + 'proj-j', + jiraProviderWizard, + ), + ).toEqual({ + providerId: 'jira', + capability: 'currentUser', + args: {}, + credentials: { + email: 'user@example.com', + api_token: 'jira-token', + base_url: 'https://example.atlassian.net', + auth_type: 'scoped', + }, + }); + }); + it('preserves provider-specific verified-as display formatting', () => { expect( trelloProviderWizard.formatVerificationDisplay({ @@ -442,6 +497,8 @@ describe('metadata-driven mutation requests', () => { email: 'user@example.com', api_token: 'jira-token', base_url: 'https://example.atlassian.net', + // auth_type rides along the shared metadata-driven auth-arg builder. + auth_type: 'basic', }, }); }); @@ -650,11 +707,18 @@ describe('jiraProviderWizard.buildIntegrationConfig', () => { expect(config).toEqual({ projectKey: 'PROJ', baseUrl: 'https://example.atlassian.net', + // authType defaults to 'basic' and is always persisted (MNG-1744). + authType: 'basic', statuses: { todo: 'To Do', done: 'Done' }, labels: { processing: 'cascade-processing' }, }); }); + it('persists authType: scoped when jiraAuthType is scoped (MNG-1744)', () => { + const config = jiraProviderWizard.buildIntegrationConfig(seed({ jiraAuthType: 'scoped' })); + expect(config.authType).toBe('scoped'); + }); + it('includes issueTypes when jiraIssueTypes non-empty', () => { const config = jiraProviderWizard.buildIntegrationConfig( seed({ jiraIssueTypes: { task: 'Task', subtask: 'Sub-task' } }), diff --git a/tests/unit/web/pm-wizard-state.test.ts b/tests/unit/web/pm-wizard-state.test.ts index 211baaa6..712aec3c 100644 --- a/tests/unit/web/pm-wizard-state.test.ts +++ b/tests/unit/web/pm-wizard-state.test.ts @@ -44,6 +44,7 @@ describe('createInitialState', () => { expect(state.jiraEmail).toBe(''); expect(state.jiraApiToken).toBe(''); expect(state.jiraBaseUrl).toBe(''); + expect(state.jiraAuthType).toBe('basic'); expect(state.verificationResult).toBeNull(); expect(state.verifyError).toBeNull(); expect(state.trelloBoardId).toBe(''); @@ -96,6 +97,7 @@ describe('provider state slices', () => { jiraEmail: '', jiraApiToken: '', jiraBaseUrl: '', + jiraAuthType: 'basic', jiraProjectKey: '', jiraProjects: [], jiraProjectDetails: null, @@ -175,6 +177,18 @@ describe('provider state slices', () => { expect(next.jiraIssueTypes).toEqual({ task: 'Task', subtask: 'Sub-task' }); }); + it('handles SET_JIRA_AUTH_TYPE in the JIRA state reducer, clearing verification', () => { + const state = { + ...createInitialState(), + verificationResult: { provider: 'jira' as const, display: 'JIRA User' }, + verifyError: 'stale', + }; + const next = jiraWizardReducer(state, { type: 'SET_JIRA_AUTH_TYPE', value: 'scoped' }); + expect(next.jiraAuthType).toBe('scoped'); + expect(next.verificationResult).toBeNull(); + expect(next.verifyError).toBeNull(); + }); + it('handles Linear team reset in the Linear state reducer', () => { const state = { ...createInitialState(), @@ -318,6 +332,25 @@ describe('wizardReducer', () => { expect(next.verifyError).toBeNull(); }); + it('SET_JIRA_AUTH_TYPE sets the auth mode and clears verification (MNG-1744)', () => { + const state = { + ...initialState(), + provider: 'jira' as const, + verificationResult: { provider: 'jira' as const, display: 'JIRA User' }, + verifyError: 'old error', + }; + const next = dispatch(state, { type: 'SET_JIRA_AUTH_TYPE', value: 'scoped' }); + expect(next.jiraAuthType).toBe('scoped'); + expect(next.verificationResult).toBeNull(); + expect(next.verifyError).toBeNull(); + }); + + it('SET_JIRA_AUTH_TYPE can switch back to basic', () => { + const state = { ...initialState(), jiraAuthType: 'scoped' as const }; + const next = dispatch(state, { type: 'SET_JIRA_AUTH_TYPE', value: 'basic' }); + expect(next.jiraAuthType).toBe('basic'); + }); + it('SET_VERIFICATION stores result and clears error', () => { const state = { ...initialState(), verifyError: 'old error' }; const next = dispatch(state, { @@ -743,6 +776,7 @@ describe('provider-owned edit hydration', () => { const config = { baseUrl: 'https://example.atlassian.net', projectKey: 'PROJ', + authType: 'scoped', statuses: { todo: 'To Do', done: 'Done' }, issueTypes: { task: 'Task', subtask: 'Subtask' }, labels: { processing: 'cascade-processing' }, @@ -754,6 +788,8 @@ describe('provider-owned edit hydration', () => { expect(result.jiraEmail).toBeUndefined(); expect(result.jiraApiToken).toBeUndefined(); expect(result.jiraBaseUrl).toBe('https://example.atlassian.net'); + // authType is hydrated from persisted config (MNG-1744). + expect(result.jiraAuthType).toBe('scoped'); expect(result.jiraProjectKey).toBe('PROJ'); expect(result.jiraStatusMappings).toEqual({ todo: 'To Do', done: 'Done' }); expect(result.jiraIssueTypes).toEqual({ task: 'Task', subtask: 'Subtask' }); @@ -761,6 +797,14 @@ describe('provider-owned edit hydration', () => { expect(result.jiraCostFieldId).toBe('customfield_10042'); }); + it('hydrates jiraAuthType to basic for legacy config without authType (MNG-1744)', () => { + const result = jiraProviderWizard.buildEditState( + { baseUrl: 'https://example.atlassian.net', projectKey: 'PROJ' }, + new Set(), + ); + expect(result.jiraAuthType).toBe('basic'); + }); + it('sets hasStoredCredentials true for jira when both keys present', () => { const result = jiraProviderWizard.buildEditState( { baseUrl: 'https://example.atlassian.net', projectKey: 'PROJ' }, diff --git a/web/src/components/projects/pm-providers/jira/auth.ts b/web/src/components/projects/pm-providers/jira/auth.ts index 7767419e..6b82b1fc 100644 --- a/web/src/components/projects/pm-providers/jira/auth.ts +++ b/web/src/components/projects/pm-providers/jira/auth.ts @@ -5,6 +5,10 @@ export const jiraAuthMetadata: ProviderAuthMetadata = { { role: 'email', stateField: 'jiraEmail' }, { role: 'api_token', stateField: 'jiraApiToken' }, { role: 'base_url', stateField: 'jiraBaseUrl' }, + // Non-secret connection setting (mirrors base_url). Always has a value + // (defaults to 'basic'), so verify-button readiness is unaffected and the + // verify credential bag auto-includes auth_type for host routing. + { role: 'auth_type', stateField: 'jiraAuthType' }, ], storedCredentials: { fallbackWhenStateFieldEmpty: 'jiraEmail' }, missingCredentialsMessage: 'Enter both credentials before verifying', diff --git a/web/src/components/projects/pm-providers/jira/hooks.ts b/web/src/components/projects/pm-providers/jira/hooks.ts index f2633f9e..a5224a73 100644 --- a/web/src/components/projects/pm-providers/jira/hooks.ts +++ b/web/src/components/projects/pm-providers/jira/hooks.ts @@ -41,6 +41,9 @@ export function useJiraDiscovery( email: state.jiraEmail, api_token: state.jiraApiToken, base_url: state.jiraBaseUrl, + // Non-secret connection setting — routes discovery through the + // correct host (site URL for basic, api.atlassian.com for scoped). + auth_type: state.jiraAuthType, }, })) as Array<{ id: string; name: string }>; return projects.map((p) => ({ key: p.id, name: p.name })); @@ -63,6 +66,9 @@ export function useJiraDiscovery( email: state.jiraEmail, apiToken: state.jiraApiToken, baseUrl: state.jiraBaseUrl, + // Non-secret connection setting — routes project-details discovery + // through the correct host (site URL for basic, gateway for scoped). + authType: state.jiraAuthType, projectKey, }); }, diff --git a/web/src/components/projects/pm-providers/jira/state.ts b/web/src/components/projects/pm-providers/jira/state.ts index 08417f28..0843b824 100644 --- a/web/src/components/projects/pm-providers/jira/state.ts +++ b/web/src/components/projects/pm-providers/jira/state.ts @@ -1,3 +1,18 @@ +/** + * JIRA authentication mode. NON-secret connection setting (mirrors + * `baseUrl`), NOT a credential — both modes still authenticate via HTTP + * Basic with `email:api_token`. The enum distinguishes the token class / + * host routing: + * - `'basic'` — classic API token; Jira REST API at the site URL. + * - `'scoped'` — scoped API token; CASCADE routes Jira REST API calls + * through the `api.atlassian.com/ex/jira/{cloudId}` gateway using the + * token's granular scopes. + * + * Single source of truth for the wizard-state literal union so + * `pm-wizard-state.ts` and the provider slice cannot drift. + */ +export type JiraWizardAuthType = 'basic' | 'scoped'; + export interface JiraProjectOption { key: string; name: string; @@ -21,6 +36,7 @@ export interface JiraWizardStateSlice { jiraEmail: string; jiraApiToken: string; jiraBaseUrl: string; + jiraAuthType: JiraWizardAuthType; jiraProjectKey: string; jiraProjects: JiraProjectOption[]; jiraProjectDetails: JiraProjectDetails | null; @@ -39,6 +55,7 @@ export type JiraWizardAction = | { type: 'SET_JIRA_EMAIL'; value: string } | { type: 'SET_JIRA_API_TOKEN'; value: string } | { type: 'SET_JIRA_BASE_URL'; url: string } + | { type: 'SET_JIRA_AUTH_TYPE'; value: JiraWizardAuthType } | { type: 'SET_JIRA_PROJECTS'; projects: JiraProjectOption[] } | { type: 'SET_JIRA_PROJECT_KEY'; key: string } | { type: 'SET_JIRA_PROJECT_DETAILS'; details: JiraProjectDetails | null } @@ -53,6 +70,7 @@ export function createInitialJiraState(): JiraWizardStateSlice { jiraEmail: '', jiraApiToken: '', jiraBaseUrl: '', + jiraAuthType: 'basic', jiraProjectKey: '', jiraProjects: [], jiraProjectDetails: null, @@ -93,6 +111,15 @@ export function jiraWizardReducer | undefined): JiraPr // ── Per-step adapters ──────────────────────────────────────────────── +// Basic vs scoped token selector options. Presented as a host-routing / +// security-scope choice, NOT a different auth protocol — the operator still +// enters email + API token in both modes (see MNG-1735 research). +const JIRA_AUTH_TYPE_OPTIONS: ReadonlyArray<{ + readonly value: JiraWizardAuthType; + readonly label: string; + readonly hint: string; +}> = [ + { + value: 'basic', + label: 'API token', + hint: 'Classic API token. CASCADE calls the Jira REST API at your site URL.', + }, + { + value: 'scoped', + label: 'API token with scopes', + hint: 'Scoped API token — CASCADE routes Jira REST API calls through the api.atlassian.com gateway using the token’s granular scopes. You still enter your email + API token.', + }, +]; + +/** + * Segmented control for the JIRA auth-type (basic vs scoped). Rendered via + * shadcn `Button` primitives (SSR-safe; no raw radio/select) and dispatches + * `SET_JIRA_AUTH_TYPE`. Shows the selected option's helper text below. + */ +function JiraAuthTypeSelector({ + value, + onChange, +}: { + value: JiraWizardAuthType; + onChange: (next: JiraWizardAuthType) => void; +}): ReactElement { + const activeHint = JIRA_AUTH_TYPE_OPTIONS.find((o) => o.value === value)?.hint ?? ''; + return createElement( + 'div', + { className: 'space-y-2', 'data-auth-type-selector': 'jira' }, + createElement(Label, null, 'Token type'), + createElement( + 'div', + { className: 'flex gap-2', role: 'radiogroup', 'aria-label': 'JIRA token type' }, + ...JIRA_AUTH_TYPE_OPTIONS.map((opt) => + createElement( + Button, + { + key: opt.value, + type: 'button', + variant: value === opt.value ? 'default' : 'outline', + size: 'sm', + role: 'radio', + 'aria-checked': value === opt.value, + 'data-auth-type-option': opt.value, + 'data-selected': value === opt.value ? 'true' : 'false', + onClick: () => onChange(opt.value), + } as React.ComponentProps & DataProps, + opt.label, + ), + ), + ), + createElement( + 'p', + { className: 'text-xs text-muted-foreground', 'data-auth-type-hint': value }, + activeHint, + ), + ); +} + function JiraCredentialsAdapter({ state, dispatch }: ProviderWizardStepProps): ReactElement { - return CredentialsStep({ - step: { kind: 'credentials', id: 'jira-credentials' }, - providerId: 'jira', - credentialRoles: JIRA_CREDENTIAL_ROLES, - values: { - base_url: state.jiraBaseUrl, - email: state.jiraEmail, - api_token: state.jiraApiToken, - }, - onChange: (role, value) => { - if (role === 'base_url') dispatch({ type: 'SET_JIRA_BASE_URL', url: value }); - else if (role === 'email') dispatch({ type: 'SET_JIRA_EMAIL', value }); - else if (role === 'api_token') dispatch({ type: 'SET_JIRA_API_TOKEN', value }); - }, - }); + return createElement( + 'div', + { className: 'space-y-4' }, + JiraAuthTypeSelector({ + value: state.jiraAuthType, + onChange: (value) => dispatch({ type: 'SET_JIRA_AUTH_TYPE', value }), + }), + CredentialsStep({ + step: { kind: 'credentials', id: 'jira-credentials' }, + providerId: 'jira', + credentialRoles: JIRA_CREDENTIAL_ROLES, + values: { + base_url: state.jiraBaseUrl, + email: state.jiraEmail, + api_token: state.jiraApiToken, + }, + onChange: (role, value) => { + if (role === 'base_url') dispatch({ type: 'SET_JIRA_BASE_URL', url: value }); + else if (role === 'email') dispatch({ type: 'SET_JIRA_EMAIL', value }); + else if (role === 'api_token') dispatch({ type: 'SET_JIRA_API_TOKEN', value }); + }, + }), + ); } function JiraProjectPickAdapter({ state, providerHooks }: ProviderWizardStepProps): ReactElement { @@ -302,6 +380,9 @@ export const jiraProviderWizard: ProviderWizardDefinition = { buildIntegrationConfig: (state) => ({ projectKey: state.jiraProjectKey, baseUrl: state.jiraBaseUrl, + // Non-secret connection setting persisted alongside baseUrl (mirrors the + // backend jiraConfigSchema.authType). Later stories read it for host routing. + authType: state.jiraAuthType, statuses: state.jiraStatusMappings, ...(Object.keys(state.jiraIssueTypes).length > 0 ? { issueTypes: state.jiraIssueTypes } : {}), ...(Object.keys(state.jiraLabels).length > 0 ? { labels: state.jiraLabels } : {}), @@ -323,6 +404,9 @@ export const jiraProviderWizard: ProviderWizardDefinition = { return { provider: 'jira', jiraBaseUrl: (initialConfig.baseUrl as string) ?? '', + // Hydrate the persisted auth mode; legacy configs without authType + // (saved before this field existed) default to 'basic'. + jiraAuthType: (initialConfig.authType as JiraWizardAuthType | undefined) ?? 'basic', jiraProjectKey: (initialConfig.projectKey as string) ?? '', ...(statuses ? { jiraStatusMappings: statuses } : {}), ...(issueTypes ? { jiraIssueTypes: issueTypes } : {}), diff --git a/web/src/components/projects/pm-wizard-state.ts b/web/src/components/projects/pm-wizard-state.ts index 78a8c99a..04a53e6d 100644 --- a/web/src/components/projects/pm-wizard-state.ts +++ b/web/src/components/projects/pm-wizard-state.ts @@ -10,6 +10,7 @@ import { type JiraProjectDetails, type JiraProjectOption, type JiraWizardAction, + type JiraWizardAuthType, jiraWizardReducer, } from './pm-providers/jira/state.js'; import { @@ -70,6 +71,7 @@ export interface WizardState { jiraEmail: string; jiraApiToken: string; jiraBaseUrl: string; + jiraAuthType: JiraWizardAuthType; linearApiKey: string; verificationResult: { provider: Provider; display: string } | null; verifyError: string | null;