From 4ad451492c14fce5f8da21057a3121e159f0407e Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Jul 2026 17:07:08 +0000 Subject: [PATCH] feat(jira): thread authType through discovery + verification endpoints --- src/api/routers/integrationsDiscovery.ts | 37 +++++- src/integrations/pm/jira/manifest.ts | 59 +++++++-- .../api/routers/integrationsDiscovery.test.ts | 124 +++++++++++++++++- .../manifest-config-to-credentials.test.ts | 50 +++++++ tests/unit/pm/jira/manifest-discovery.test.ts | 72 ++++++++++ tests/unit/pm/jira/manifest-mutations.test.ts | 39 ++++++ 6 files changed, 359 insertions(+), 22 deletions(-) diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index 20d02e330..138dd7fda 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -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({ @@ -63,10 +71,20 @@ async function withTrelloCreds( async function withJiraCreds( input: z.infer, label: string, - fn: (creds: { email: string; apiToken: string; baseUrl: string }) => Promise, + fn: (creds: { + email: string; + apiToken: string; + baseUrl: string; + authType?: 'basic' | 'scoped'; + }) => Promise, ): Promise { 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, + }), ); } @@ -205,14 +223,21 @@ export const integrationsDiscoveryRouter = router({ 'jira', 'api_token', ); - const baseUrl = (integration.config as Record | null)?.baseUrl as - | string - | undefined; + const config = integration.config as Record | 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), diff --git a/src/integrations/pm/jira/manifest.ts b/src/integrations/pm/jira/manifest.ts index d1249511b..46b72fe65 100644 --- a/src/integrations/pm/jira/manifest.ts +++ b/src/integrations/pm/jira/manifest.ts @@ -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 @@ -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`, 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 = { @@ -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', @@ -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 => { if (!config || typeof config !== 'object') return {}; + const promoted: Record = {}; 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, @@ -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 = (fn: () => Promise): Promise => - withJiraCredentials({ email, apiToken, baseUrl }, fn); + withJiraCredentials({ email, apiToken, baseUrl, authType }, fn); const provider: Pick = { type: 'jira', diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index 240ff2f41..40ea204a6 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -9,6 +9,7 @@ const { mockTrelloGetBoardLabels, mockTrelloGetBoardCustomFields, mockTrelloCreateBoardCustomField, + mockWithJiraCredentials, mockJiraGetMyself, mockJiraSearchProjects, mockJiraGetProjectStatuses, @@ -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(), @@ -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, @@ -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[0]; + await expect(caller.jiraProjectDetails(badInput)).rejects.toThrow(); + }); + it('rejects lowercase projectKey', async () => { const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); await expect( @@ -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('stored@example.com') + .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: 'stored@example.com', + 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('stored@example.com') + .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('stored@example.com') + .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({ diff --git a/tests/unit/pm/jira/manifest-config-to-credentials.test.ts b/tests/unit/pm/jira/manifest-config-to-credentials.test.ts index a212ad587..29a3ea636 100644 --- a/tests/unit/pm/jira/manifest-config-to-credentials.test.ts +++ b/tests/unit/pm/jira/manifest-config-to-credentials.test.ts @@ -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; + + 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({}); + }); +}); diff --git a/tests/unit/pm/jira/manifest-discovery.test.ts b/tests/unit/pm/jira/manifest-discovery.test.ts index bf1763717..2d697ef37 100644 --- a/tests/unit/pm/jira/manifest-discovery.test.ts +++ b/tests/unit/pm/jira/manifest-discovery.test.ts @@ -49,6 +49,7 @@ vi.mock('../../../../src/jira/client.js', () => { }); import { jiraManifest } from '../../../../src/integrations/pm/jira/manifest.js'; +import { withJiraCredentials } from '../../../../src/jira/client.js'; describe('jiraManifest.discoveryCapabilities', () => { it('declares projects, states, labels, customFields, currentUser', () => { @@ -131,3 +132,74 @@ describe('jiraManifest.discover via createDiscoveryProvider', () => { }); }); }); + +/** + * MNG-1743: the discovery factory reads `creds.auth_type` and threads it into + * `withJiraCredentials` so wizard-time / edit-mode verification calls route + * through the correct host (site URL for basic, Atlassian gateway for scoped). + */ +describe('jiraManifest.createDiscoveryProvider threads authType (MNG-1743)', () => { + function makeProviderWith(credentials: Record) { + if (!jiraManifest.createDiscoveryProvider) { + throw new Error('createDiscoveryProvider missing on jiraManifest'); + } + return jiraManifest.createDiscoveryProvider({ credentials }); + } + + it("forwards auth_type: 'scoped' into withJiraCredentials", async () => { + const provider = makeProviderWith({ + email: 'user@example.com', + api_token: 'tok', + base_url: 'https://example.atlassian.net', + auth_type: 'scoped', + }); + await provider.discover?.('projects', {}); + expect(withJiraCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'user@example.com', + apiToken: 'tok', + baseUrl: 'https://example.atlassian.net', + authType: 'scoped', + }), + expect.any(Function), + ); + }); + + it("forwards auth_type: 'basic' into withJiraCredentials", async () => { + const provider = makeProviderWith({ + email: 'user@example.com', + api_token: 'tok', + base_url: 'https://example.atlassian.net', + auth_type: 'basic', + }); + await provider.discover?.('currentUser', {}); + expect(withJiraCredentials).toHaveBeenCalledWith( + expect.objectContaining({ authType: 'basic' }), + expect.any(Function), + ); + }); + + it('passes authType undefined when auth_type is absent (defaults to basic downstream)', async () => { + const provider = makeProviderWith({ + email: 'user@example.com', + api_token: 'tok', + base_url: 'https://example.atlassian.net', + }); + await provider.discover?.('projects', {}); + const call = vi.mocked(withJiraCredentials).mock.calls.at(-1); + expect(call).toBeDefined(); + expect((call?.[0] as { authType?: string }).authType).toBeUndefined(); + }); + + it('passes authType undefined for an unrecognized auth_type value', async () => { + const provider = makeProviderWith({ + email: 'user@example.com', + api_token: 'tok', + base_url: 'https://example.atlassian.net', + auth_type: 'bearer', + }); + await provider.discover?.('projects', {}); + const call = vi.mocked(withJiraCredentials).mock.calls.at(-1); + expect((call?.[0] as { authType?: string }).authType).toBeUndefined(); + }); +}); diff --git a/tests/unit/pm/jira/manifest-mutations.test.ts b/tests/unit/pm/jira/manifest-mutations.test.ts index b04569ebb..d08ea52ab 100644 --- a/tests/unit/pm/jira/manifest-mutations.test.ts +++ b/tests/unit/pm/jira/manifest-mutations.test.ts @@ -23,6 +23,7 @@ vi.mock('../../../../src/jira/client.js', () => ({ })); import { jiraManifest } from '../../../../src/integrations/pm/jira/manifest.js'; +import { withJiraCredentials } from '../../../../src/jira/client.js'; describe('jiraManifest.createCustomField (plan 010/1)', () => { it('is declared', () => { @@ -40,6 +41,44 @@ describe('jiraManifest.createCustomField (plan 010/1)', () => { expect(result).toMatchObject({ name: 'Cost' }); expect(result.type).toBeTruthy(); }); + + // MNG-1743: thread the configured auth mode into withJiraCredentials so the + // createCustomField call routes through the correct host under scoped auth. + it("threads auth_type: 'scoped' from credentials into withJiraCredentials", async () => { + const hook = jiraManifest.createCustomField; + if (!hook) throw new Error('createCustomField should be defined'); + await hook({ + credentials: { + email: 'a@b.com', + api_token: 't', + base_url: 'https://x.atlassian.net', + auth_type: 'scoped', + }, + containerId: 'CASC', + name: 'Cost', + }); + expect(withJiraCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'a@b.com', + apiToken: 't', + baseUrl: 'https://x.atlassian.net', + authType: 'scoped', + }), + expect.any(Function), + ); + }); + + it('passes authType undefined when auth_type is absent', async () => { + const hook = jiraManifest.createCustomField; + if (!hook) throw new Error('createCustomField should be defined'); + await hook({ + credentials: { email: 'a@b.com', api_token: 't', base_url: 'https://x.atlassian.net' }, + containerId: 'CASC', + name: 'Cost', + }); + const call = vi.mocked(withJiraCredentials).mock.calls.at(-1); + expect((call?.[0] as { authType?: string }).authType).toBeUndefined(); + }); }); describe('jiraManifest does NOT declare createLabel (free-form labels)', () => {