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
111 changes: 111 additions & 0 deletions tests/unit/web/jira-credentials-auth-type.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<string, unknown>)['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<string, unknown>)['data-auth-type-option'] === 'basic',
);
(basic?.props as { onClick: () => void }).onClick();
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_JIRA_AUTH_TYPE', value: 'basic' });
});
});
64 changes: 64 additions & 0 deletions tests/unit/web/pm-wizard-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,29 @@ describe('provider credential metadata', () => {
email: '[email protected]',
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: '[email protected]',
jiraApiToken: 'jira-tok',
jiraBaseUrl: 'https://example.atlassian.net',
jiraAuthType: 'scoped',
}),
'proj-j',
jiraProviderWizard.auth,
),
).toEqual({
credentials: {
email: '[email protected]',
api_token: 'jira-tok',
base_url: 'https://example.atlassian.net',
auth_type: 'scoped',
},
});
expect(
Expand Down Expand Up @@ -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',
);
Expand Down Expand Up @@ -266,6 +294,8 @@ describe('metadata-driven verification request', () => {
email: '[email protected]',
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(
Expand All @@ -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: '[email protected]',
jiraApiToken: 'jira-token',
jiraBaseUrl: 'https://example.atlassian.net',
jiraAuthType: 'scoped',
}),
'proj-j',
jiraProviderWizard,
),
).toEqual({
providerId: 'jira',
capability: 'currentUser',
args: {},
credentials: {
email: '[email protected]',
api_token: 'jira-token',
base_url: 'https://example.atlassian.net',
auth_type: 'scoped',
},
});
});

it('preserves provider-specific verified-as display formatting', () => {
expect(
trelloProviderWizard.formatVerificationDisplay({
Expand Down Expand Up @@ -442,6 +497,8 @@ describe('metadata-driven mutation requests', () => {
email: '[email protected]',
api_token: 'jira-token',
base_url: 'https://example.atlassian.net',
// auth_type rides along the shared metadata-driven auth-arg builder.
auth_type: 'basic',
},
});
});
Expand Down Expand Up @@ -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' } }),
Expand Down
44 changes: 44 additions & 0 deletions tests/unit/web/pm-wizard-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -96,6 +97,7 @@ describe('provider state slices', () => {
jiraEmail: '',
jiraApiToken: '',
jiraBaseUrl: '',
jiraAuthType: 'basic',
jiraProjectKey: '',
jiraProjects: [],
jiraProjectDetails: null,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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' },
Expand All @@ -754,13 +788,23 @@ 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' });
expect(result.jiraLabels).toEqual({ processing: 'cascade-processing' });
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<string>(),
);
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' },
Expand Down
4 changes: 4 additions & 0 deletions web/src/components/projects/pm-providers/jira/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions web/src/components/projects/pm-providers/jira/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand All @@ -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,
});
},
Expand Down
Loading
Loading