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
37 changes: 31 additions & 6 deletions src/api/routers/integrationsDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ const jiraCredsInput = z.object({
email: z.string().min(1),
apiToken: z.string().min(1),
baseUrl: z.string().url(),
/**
* Optional JIRA auth mode. Non-secret connection setting mirroring
* `baseUrl` (NOT a credential). Threaded into `withJiraCredentials` so
* wizard-time verification routes through the correct host — the classic
* site URL for `basic`, the Atlassian gateway for `scoped`. Absent ⇒
* downstream treats it as `basic` (the historical default).
*/
authType: z.enum(['basic', 'scoped']).optional(),
});

const linearCredsInput = z.object({
Expand All @@ -63,10 +71,20 @@ async function withTrelloCreds<T>(
async function withJiraCreds<T>(
input: z.infer<typeof jiraCredsInput>,
label: string,
fn: (creds: { email: string; apiToken: string; baseUrl: string }) => Promise<T>,
fn: (creds: {
email: string;
apiToken: string;
baseUrl: string;
authType?: 'basic' | 'scoped';
}) => Promise<T>,
): Promise<T> {
return wrapIntegrationCall(label, () =>
fn({ email: input.email, apiToken: input.apiToken, baseUrl: input.baseUrl }),
fn({
email: input.email,
apiToken: input.apiToken,
baseUrl: input.baseUrl,
authType: input.authType,
}),
);
}

Expand Down Expand Up @@ -205,14 +223,21 @@ export const integrationsDiscoveryRouter = router({
'jira',
'api_token',
);
const baseUrl = (integration.config as Record<string, unknown> | null)?.baseUrl as
| string
| undefined;
const config = integration.config as Record<string, unknown> | null;
const baseUrl = config?.baseUrl as string | undefined;
// Resolve the saved auth mode from config (mirrors `baseUrl` — a
// non-secret connection setting, not a credential). Threading it into
// `withJiraCredentials` makes edit-mode re-verification for scoped
// projects route through the Atlassian gateway instead of the site
// URL. Unknown / absent ⇒ undefined ⇒ downstream treats it as basic.
const rawAuthType = config?.authType;
const authType =
rawAuthType === 'basic' || rawAuthType === 'scoped' ? rawAuthType : undefined;
if (!email || !apiToken || !baseUrl) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'JIRA credentials not configured' });
}
return wrapIntegrationCall('Failed to fetch JIRA project details', () =>
withJiraCredentials({ email, apiToken, baseUrl }, () =>
withJiraCredentials({ email, apiToken, baseUrl, authType }, () =>
Promise.all([
jiraClient.getProjectStatuses(input.projectKey),
jiraClient.getIssueTypesForProject(input.projectKey),
Expand Down
59 changes: 47 additions & 12 deletions src/integrations/pm/jira/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { JiraReadyToProcessLabelTrigger } from '../../../triggers/jira/label-add
import { JiraStatusChangedTrigger } from '../../../triggers/jira/status-changed.js';
import { makeHmacSha256Verifier } from '../_shared/webhook-verifier.js';
import type { PMProviderManifest } from '../manifest.js';
import { jiraConfigSchema } from './config-schema.js';
import { type JiraAuthType, jiraConfigSchema } from './config-schema.js';

/**
* Coerce a JIRA status name to a CASCADE-canonical category. JIRA
Expand All @@ -50,6 +50,20 @@ function classifyJiraStatus(
return 'unknown';
}

/**
* Narrow a raw `auth_type` value to the {@link JiraAuthType} union. Accepts
* `unknown` so it can guard both the discovery credentials bag (a
* `Record<string, string>`, where the value is a plain string or `undefined`)
* and the persisted integration config (where `authType` is untyped JSON).
* Anything other than the two recognized modes returns `undefined`, which the
* JIRA client + host resolver (`resolveJiraApiBaseUrl`) treat as `'basic'` —
* the historical default. This keeps unknown / absent values from ever
* selecting the scoped gateway host.
*/
function coerceJiraAuthType(value: unknown): JiraAuthType | undefined {
return value === 'basic' || value === 'scoped' ? value : undefined;
}

const jiraIntegration = new JiraIntegration();

export const jiraManifest: PMProviderManifest = {
Expand Down Expand Up @@ -101,7 +115,11 @@ export const jiraManifest: PMProviderManifest = {
const email = credentials.email ?? '';
const apiToken = credentials.api_token ?? '';
const baseUrl = credentials.base_url ?? '';
return withJiraCredentials({ email, apiToken, baseUrl }, async () => {
// Carry the configured auth mode so the JIRA client routes the
// createCustomField call through the correct host (site vs. scoped
// gateway). Absent ⇒ downstream treats it as 'basic'.
const authType = coerceJiraAuthType(credentials.auth_type);
return withJiraCredentials({ email, apiToken, baseUrl, authType }, async () => {
const result = await jiraClient.createCustomField(
name,
'com.atlassian.jira.plugin.system.customfieldtypes:float',
Expand Down Expand Up @@ -134,19 +152,29 @@ export const jiraManifest: PMProviderManifest = {
},

/**
* JIRA's cloud tenant URL is a non-secret connection field stored on
* `project_integrations.config.baseUrl`, not `project_credentials`. The
* pm-discovery resolver invokes this hook on the projectId path to
* promote the URL into the credentials bag the `createDiscoveryProvider`
* factory below consumes. Without it, edit-mode re-verification in the
* wizard constructs `new Version3Client({ host: '' })` and throws
* "Couldn't parse the host URL" (prod incident 2026-04-24).
* JIRA's cloud tenant URL and auth mode are non-secret connection fields
* stored on `project_integrations.config` (`baseUrl` / `authType`), not
* `project_credentials`. The pm-discovery resolver invokes this hook on the
* projectId path to promote them into the credentials bag the
* `createDiscoveryProvider` factory below consumes. Without `base_url`,
* edit-mode re-verification in the wizard constructs
* `new Version3Client({ host: '' })` and throws "Couldn't parse the host
* URL" (prod incident 2026-04-24). Without `auth_type`, scoped projects
* re-verify against the classic site host instead of the Atlassian gateway
* (MNG-1743).
*/
configToCredentials: (config: unknown): Record<string, string> => {
if (!config || typeof config !== 'object') return {};
const promoted: Record<string, string> = {};
const baseUrl = (config as { baseUrl?: unknown }).baseUrl;
if (typeof baseUrl !== 'string' || baseUrl.length === 0) return {};
return { base_url: baseUrl };
if (typeof baseUrl === 'string' && baseUrl.length > 0) {
promoted.base_url = baseUrl;
}
const authType = coerceJiraAuthType((config as { authType?: unknown }).authType);
if (authType) {
promoted.auth_type = authType;
}
return promoted;
},

configSchema: jiraConfigSchema,
Expand Down Expand Up @@ -195,9 +223,16 @@ export const jiraManifest: PMProviderManifest = {
const email = creds.email ?? '';
const apiToken = creds.api_token ?? '';
const baseUrl = creds.base_url ?? '';
// Carry the configured auth mode so wizard-time / edit-mode discovery
// (currentUser / projects / states / customFields) routes through the
// correct host — the classic site URL for basic, the Atlassian gateway
// for scoped. `configToCredentials` promotes `config.authType` into
// `auth_type` on the projectId (edit-mode) path; the raw-creds
// (first-time setup) path receives it straight from the wizard.
const authType = coerceJiraAuthType(creds.auth_type);

const runWithCreds = <T>(fn: () => Promise<T>): Promise<T> =>
withJiraCredentials({ email, apiToken, baseUrl }, fn);
withJiraCredentials({ email, apiToken, baseUrl, authType }, fn);

const provider: Pick<PMProvider, 'type' | 'discover'> = {
type: 'jira',
Expand Down
124 changes: 120 additions & 4 deletions tests/unit/api/routers/integrationsDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
mockTrelloGetBoardLabels,
mockTrelloGetBoardCustomFields,
mockTrelloCreateBoardCustomField,
mockWithJiraCredentials,
mockJiraGetMyself,
mockJiraSearchProjects,
mockJiraGetProjectStatuses,
Expand All @@ -32,6 +33,7 @@ const {
mockTrelloGetBoardLabels: vi.fn(),
mockTrelloGetBoardCustomFields: vi.fn(),
mockTrelloCreateBoardCustomField: vi.fn(),
mockWithJiraCredentials: vi.fn((_creds: unknown, cb: () => unknown) => cb()),
mockJiraGetMyself: vi.fn(),
mockJiraSearchProjects: vi.fn(),
mockJiraGetProjectStatuses: vi.fn(),
Expand Down Expand Up @@ -66,10 +68,7 @@ vi.mock('../../../../src/trello/client.js', () => ({
}));

vi.mock('../../../../src/jira/client.js', () => ({
withJiraCredentials: (...args: unknown[]) => {
const cb = args[1] as () => unknown;
return cb();
},
withJiraCredentials: mockWithJiraCredentials,
jiraClient: {
getMyself: mockJiraGetMyself,
searchProjects: mockJiraSearchProjects,
Expand Down Expand Up @@ -319,6 +318,56 @@ describe('integrationsDiscoveryRouter', () => {
expect(mockJiraGetIssueTypesForProject).toHaveBeenCalledWith('PROJ');
});

// MNG-1743: authType from the wizard form is threaded into
// withJiraCredentials so raw-creds verification routes through the
// correct host (site URL for basic, Atlassian gateway for scoped).
it('threads authType into withJiraCredentials (raw-creds path)', async () => {
mockJiraGetProjectStatuses.mockResolvedValue([]);
mockJiraGetIssueTypesForProject.mockResolvedValue([]);
mockJiraGetFields.mockResolvedValue([]);

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await caller.jiraProjectDetails({
...jiraCredsInput,
authType: 'scoped',
projectKey: 'PROJ',
});

expect(mockWithJiraCredentials).toHaveBeenCalledWith(
expect.objectContaining({
email: jiraCredsInput.email,
apiToken: jiraCredsInput.apiToken,
baseUrl: jiraCredsInput.baseUrl,
authType: 'scoped',
}),
expect.any(Function),
);
});

it('passes authType undefined when omitted (raw-creds path)', async () => {
mockJiraGetProjectStatuses.mockResolvedValue([]);
mockJiraGetIssueTypesForProject.mockResolvedValue([]);
mockJiraGetFields.mockResolvedValue([]);

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await caller.jiraProjectDetails({ ...jiraCredsInput, projectKey: 'PROJ' });

const call = mockWithJiraCredentials.mock.calls.at(-1);
expect((call?.[0] as { authType?: string }).authType).toBeUndefined();
});

it('rejects an unknown authType value at the input boundary', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
// `authType` is a `basic | scoped` enum — cast past the compile-time
// type to prove the runtime Zod enum rejects anything else.
const badInput = {
...jiraCredsInput,
authType: 'bearer',
projectKey: 'PROJ',
} as unknown as Parameters<typeof caller.jiraProjectDetails>[0];
await expect(caller.jiraProjectDetails(badInput)).rejects.toThrow();
});

it('rejects lowercase projectKey', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
Expand Down Expand Up @@ -446,6 +495,73 @@ describe('integrationsDiscoveryRouter', () => {
]);
});

// MNG-1743: resolve the saved auth mode from integration config and
// thread it into withJiraCredentials so edit-mode re-verification for
// scoped projects routes through the Atlassian gateway host.
it('resolves authType from saved config and threads it into withJiraCredentials', async () => {
mockGetIntegrationCredentialOrNull
.mockResolvedValueOnce('[email protected]')
.mockResolvedValueOnce('stored-token');
mockGetIntegrationByProjectAndCategory.mockResolvedValue({
provider: 'jira',
config: { baseUrl: 'https://myorg.atlassian.net', authType: 'scoped' },
});
mockJiraGetProjectStatuses.mockResolvedValue([]);
mockJiraGetIssueTypesForProject.mockResolvedValue([]);
mockJiraGetFields.mockResolvedValue([]);

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await caller.jiraProjectDetailsByProject({ projectId: 'proj-1', projectKey: 'PROJ' });

expect(mockWithJiraCredentials).toHaveBeenCalledWith(
expect.objectContaining({
email: '[email protected]',
apiToken: 'stored-token',
baseUrl: 'https://myorg.atlassian.net',
authType: 'scoped',
}),
expect.any(Function),
);
});

it('passes authType undefined when saved config has no authType (stored-creds path)', async () => {
mockGetIntegrationCredentialOrNull
.mockResolvedValueOnce('[email protected]')
.mockResolvedValueOnce('stored-token');
mockGetIntegrationByProjectAndCategory.mockResolvedValue({
provider: 'jira',
config: { baseUrl: 'https://myorg.atlassian.net' },
});
mockJiraGetProjectStatuses.mockResolvedValue([]);
mockJiraGetIssueTypesForProject.mockResolvedValue([]);
mockJiraGetFields.mockResolvedValue([]);

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await caller.jiraProjectDetailsByProject({ projectId: 'proj-1', projectKey: 'PROJ' });

const call = mockWithJiraCredentials.mock.calls.at(-1);
expect((call?.[0] as { authType?: string }).authType).toBeUndefined();
});

it('ignores an unrecognized authType stored in config (stored-creds path)', async () => {
mockGetIntegrationCredentialOrNull
.mockResolvedValueOnce('[email protected]')
.mockResolvedValueOnce('stored-token');
mockGetIntegrationByProjectAndCategory.mockResolvedValue({
provider: 'jira',
config: { baseUrl: 'https://myorg.atlassian.net', authType: 'bearer' },
});
mockJiraGetProjectStatuses.mockResolvedValue([]);
mockJiraGetIssueTypesForProject.mockResolvedValue([]);
mockJiraGetFields.mockResolvedValue([]);

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await caller.jiraProjectDetailsByProject({ projectId: 'proj-1', projectKey: 'PROJ' });

const call = mockWithJiraCredentials.mock.calls.at(-1);
expect((call?.[0] as { authType?: string }).authType).toBeUndefined();
});

it('throws NOT_FOUND when credentials are missing', async () => {
mockGetIntegrationCredentialOrNull.mockResolvedValue(null);
mockGetIntegrationByProjectAndCategory.mockResolvedValue({
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/pm/jira/manifest-config-to-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,53 @@ describe('jiraManifest.configToCredentials', () => {
).toEqual({ base_url: 'https://acme.atlassian.net' });
});
});

/**
* MNG-1743: the hook also promotes `config.authType → auth_type` so the
* projectId (edit-mode) path of `pm.discovery.*` re-verifies scoped JIRA
* projects against the Atlassian gateway host instead of the classic site URL.
*/
describe('jiraManifest.configToCredentials — authType promotion (MNG-1743)', () => {
let hook: NonNullable<typeof jiraManifest.configToCredentials>;

beforeAll(() => {
const declared = jiraManifest.configToCredentials;
if (!declared) {
throw new Error('jiraManifest.configToCredentials must be declared');
}
hook = declared;
});

it("promotes authType: 'scoped' into auth_type alongside base_url", () => {
expect(hook({ baseUrl: 'https://acme.atlassian.net', authType: 'scoped' })).toEqual({
base_url: 'https://acme.atlassian.net',
auth_type: 'scoped',
});
});

it("promotes authType: 'basic' into auth_type alongside base_url", () => {
expect(hook({ baseUrl: 'https://acme.atlassian.net', authType: 'basic' })).toEqual({
base_url: 'https://acme.atlassian.net',
auth_type: 'basic',
});
});

it('promotes authType even when baseUrl is absent', () => {
expect(hook({ authType: 'scoped' })).toEqual({ auth_type: 'scoped' });
});

it('omits auth_type when authType is absent (backward compat)', () => {
expect(hook({ baseUrl: 'https://acme.atlassian.net' })).toEqual({
base_url: 'https://acme.atlassian.net',
});
});

it('omits auth_type for unknown / invalid authType values', () => {
expect(hook({ baseUrl: 'https://acme.atlassian.net', authType: 'bearer' })).toEqual({
base_url: 'https://acme.atlassian.net',
});
expect(hook({ authType: '' })).toEqual({});
expect(hook({ authType: 123 })).toEqual({});
expect(hook({ authType: null })).toEqual({});
});
});
Loading
Loading