Skip to content

Commit b93f8b5

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(atlassian): resolve Jira and Confluence selector contexts server-side
1 parent 17726cb commit b93f8b5

13 files changed

Lines changed: 913 additions & 270 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
authenticate: vi.fn(),
9+
resolveContext: vi.fn(),
10+
resolveAtlassianCredential: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/selectors/server/resolve-authorized-context', () => ({
14+
authenticateSelectorRequest: mocks.authenticate,
15+
resolveAuthorizedSelectorContext: mocks.resolveContext,
16+
}))
17+
vi.mock('@/lib/selectors/application/atlassian-credential', () => ({
18+
resolveAtlassianSelectorCredential: mocks.resolveAtlassianCredential,
19+
}))
20+
21+
import { POST as confluencePages } from '@/app/api/tools/confluence/selector-pages/route'
22+
import { POST as jiraProject } from '@/app/api/tools/jira/projects/route'
23+
24+
const principal = {
25+
kind: 'session',
26+
userId: 'viewer-1',
27+
sessionId: 'session-1',
28+
} as const
29+
30+
function request(path: string, body: unknown) {
31+
return createMockRequest(
32+
'POST',
33+
body,
34+
{ 'content-type': 'application/json' },
35+
`http://localhost:3000${path}`
36+
)
37+
}
38+
39+
describe('server-resolved Atlassian selector routes', () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks()
42+
vi.unstubAllGlobals()
43+
mocks.authenticate.mockResolvedValue({ ok: true, principal })
44+
mocks.resolveContext.mockImplementation(
45+
async (_principal: unknown, input: { context: Record<string, unknown> }) => ({
46+
ok: true,
47+
context: { ...input.context, domain: 'resolved-secret.example.com' },
48+
requesterUserId: 'viewer-1',
49+
workspaceId: 'workspace-1',
50+
credentialAccess: { credentialOwnerUserId: 'owner-1' },
51+
})
52+
)
53+
mocks.resolveAtlassianCredential.mockResolvedValue({
54+
accessToken: 'atlassian-token',
55+
cloudId: 'cloud-id-1',
56+
})
57+
})
58+
59+
it('authenticates before parsing a malformed request', async () => {
60+
mocks.authenticate.mockResolvedValue({ ok: false, status: 401, error: 'Unauthorized' })
61+
62+
const response = await jiraProject(
63+
request('/api/tools/jira/projects', { definitely: 'not a Jira selector request' })
64+
)
65+
66+
expect(response.status).toBe(401)
67+
expect(await response.json()).toEqual({ error: 'Unauthorized' })
68+
expect(mocks.resolveContext).not.toHaveBeenCalled()
69+
})
70+
71+
it.each([
72+
{
73+
name: 'Jira Project',
74+
route: jiraProject,
75+
path: '/api/tools/jira/projects',
76+
body: {
77+
credential: 'credential-1',
78+
workflowId: 'workflow-1',
79+
domain: '{{INACCESSIBLE_SECRET}}',
80+
projectId: 'SIM',
81+
},
82+
},
83+
{
84+
name: 'Confluence Page',
85+
route: confluencePages,
86+
path: '/api/tools/confluence/selector-pages',
87+
body: {
88+
credential: 'credential-1',
89+
workflowId: 'workflow-1',
90+
domain: '{{INACCESSIBLE_SECRET}}',
91+
},
92+
},
93+
])('$name stops inaccessible references before provider access', async (testCase) => {
94+
mocks.resolveContext.mockResolvedValue({
95+
ok: false,
96+
status: 400,
97+
error: 'Unable to resolve selector configuration',
98+
})
99+
const providerFetch = vi.fn()
100+
vi.stubGlobal('fetch', providerFetch)
101+
102+
const response = await testCase.route(request(testCase.path, testCase.body))
103+
104+
expect(response.status).toBe(400)
105+
expect(await response.json()).toEqual({ error: 'Unable to resolve selector configuration' })
106+
expect(mocks.resolveAtlassianCredential).not.toHaveBeenCalled()
107+
expect(providerFetch).not.toHaveBeenCalled()
108+
})
109+
110+
it('maps Jira projects without exposing resolved provider data', async () => {
111+
vi.stubGlobal(
112+
'fetch',
113+
vi
114+
.fn()
115+
.mockResolvedValue(
116+
Response.json({ id: '10001', name: 'Sim', self: 'https://resolved-secret.example.com' })
117+
)
118+
)
119+
120+
const response = await jiraProject(
121+
request('/api/tools/jira/projects', {
122+
credential: 'credential-1',
123+
workflowId: 'workflow-1',
124+
domain: '{{DOMAIN}}',
125+
projectId: 'SIM',
126+
})
127+
)
128+
129+
expect(await response.json()).toEqual({ project: { id: '10001', name: 'Sim' } })
130+
})
131+
132+
it('maps Confluence pages without exposing resolved provider data', async () => {
133+
vi.stubGlobal(
134+
'fetch',
135+
vi.fn().mockResolvedValue(
136+
Response.json({
137+
results: [
138+
{
139+
id: '20001',
140+
title: 'Runbook',
141+
_links: { webui: 'https://resolved-secret.example.com/wiki/runbook' },
142+
},
143+
],
144+
})
145+
)
146+
)
147+
148+
const response = await confluencePages(
149+
request('/api/tools/confluence/selector-pages', {
150+
credential: 'credential-1',
151+
workflowId: 'workflow-1',
152+
domain: '{{DOMAIN}}',
153+
})
154+
)
155+
156+
expect(await response.json()).toEqual({ files: [{ id: '20001', name: 'Runbook' }] })
157+
})
158+
159+
it('maps provider failures to a stable public response without reading their body', async () => {
160+
const providerText = vi.fn().mockResolvedValue('provider-body-secret-marker')
161+
vi.stubGlobal(
162+
'fetch',
163+
vi.fn().mockResolvedValue({ ok: false, status: 418, text: providerText })
164+
)
165+
166+
const response = await jiraProject(
167+
request('/api/tools/jira/projects', {
168+
credential: 'credential-1',
169+
workflowId: 'workflow-1',
170+
domain: '{{DOMAIN}}',
171+
projectId: 'SIM',
172+
})
173+
)
174+
175+
expect(response.status).toBe(502)
176+
expect(await response.json()).toEqual({
177+
error: 'Jira selector discovery failed.',
178+
status: 502,
179+
})
180+
expect(providerText).not.toHaveBeenCalled()
181+
})
182+
})
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { confluenceSelectorPageContract } from '@/lib/api/contracts/selectors/confluence'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { resolveAtlassianSelectorCredential } from '@/lib/selectors/application/atlassian-credential'
9+
import {
10+
resolveSelectorProviderValue,
11+
SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS,
12+
selectorProviderFailure,
13+
} from '@/lib/selectors/server/provider-errors'
14+
import {
15+
authenticateSelectorRequest,
16+
resolveAuthorizedSelectorContext,
17+
} from '@/lib/selectors/server/resolve-authorized-context'
18+
import { getConfluenceCloudId } from '@/tools/confluence/utils'
19+
20+
const logger = createLogger('ConfluenceSelectorPageAPI')
21+
22+
export const dynamic = 'force-dynamic'
23+
24+
interface ConfluencePageResponse {
25+
id: string
26+
title: string
27+
}
28+
29+
export const POST = withRouteHandler(async (request: NextRequest) => {
30+
try {
31+
const authentication = await authenticateSelectorRequest(request)
32+
if (!authentication.ok) {
33+
return NextResponse.json({ error: authentication.error }, { status: authentication.status })
34+
}
35+
const parsed = await parseRequest(confluenceSelectorPageContract, request, {})
36+
if (!parsed.success) return parsed.response
37+
38+
const { credential, workflowId, domain: domainReference, pageId } = parsed.data.body
39+
const resolution = await resolveAuthorizedSelectorContext(authentication.principal, {
40+
workflowId,
41+
credentialId: credential,
42+
context: { domain: domainReference },
43+
})
44+
if (!resolution.ok) {
45+
return NextResponse.json({ error: resolution.error }, { status: resolution.status })
46+
}
47+
48+
const credentialOwnerUserId = resolution.credentialAccess?.credentialOwnerUserId
49+
if (!credentialOwnerUserId) {
50+
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
51+
}
52+
const bundle = await resolveAtlassianSelectorCredential({
53+
credentialId: credential,
54+
credentialOwnerUserId,
55+
requestId: generateRequestId(),
56+
serviceId: 'confluence',
57+
})
58+
if (!bundle) {
59+
return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 })
60+
}
61+
62+
const domain = resolution.context.domain as string
63+
const cloudIdResolution = await resolveSelectorProviderValue('Confluence', async () =>
64+
bundle.cloudId
65+
? bundle.cloudId
66+
: getConfluenceCloudId(domain, bundle.accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS)
67+
)
68+
if (!cloudIdResolution.ok) {
69+
logger.warn('Confluence selector discovery failed', {
70+
status: cloudIdResolution.upstreamStatus ?? 'unknown',
71+
})
72+
return NextResponse.json(cloudIdResolution.failure, {
73+
status: cloudIdResolution.failure.status,
74+
})
75+
}
76+
const cloudId = cloudIdResolution.value
77+
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
78+
if (!cloudIdValidation.isValid) {
79+
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
80+
}
81+
82+
const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/pages/${pageId}`
83+
const response = await fetch(url, {
84+
method: 'GET',
85+
headers: { Accept: 'application/json', Authorization: `Bearer ${bundle.accessToken}` },
86+
})
87+
if (!response.ok) {
88+
logger.warn('Confluence selector page request failed', { status: response.status })
89+
const failure = selectorProviderFailure('Confluence', response.status)
90+
return NextResponse.json(failure, { status: failure.status })
91+
}
92+
93+
const page = (await response.json()) as ConfluencePageResponse
94+
return NextResponse.json({ id: page.id, title: page.title })
95+
} catch {
96+
logger.error('Error retrieving Confluence selector page')
97+
return NextResponse.json({ error: 'Failed to retrieve Confluence page' }, { status: 500 })
98+
}
99+
})
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { confluenceSelectorPagesContract } from '@/lib/api/contracts/selectors/confluence'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { resolveAtlassianSelectorCredential } from '@/lib/selectors/application/atlassian-credential'
9+
import {
10+
resolveSelectorProviderValue,
11+
SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS,
12+
selectorProviderFailure,
13+
} from '@/lib/selectors/server/provider-errors'
14+
import {
15+
authenticateSelectorRequest,
16+
resolveAuthorizedSelectorContext,
17+
} from '@/lib/selectors/server/resolve-authorized-context'
18+
import { getConfluenceCloudId } from '@/tools/confluence/utils'
19+
20+
const logger = createLogger('ConfluenceSelectorPagesAPI')
21+
22+
export const dynamic = 'force-dynamic'
23+
24+
interface ConfluencePageRow {
25+
id: string
26+
title: string
27+
}
28+
29+
interface ConfluencePagesResponse {
30+
results?: ConfluencePageRow[]
31+
}
32+
33+
export const POST = withRouteHandler(async (request: NextRequest) => {
34+
try {
35+
const authentication = await authenticateSelectorRequest(request)
36+
if (!authentication.ok) {
37+
return NextResponse.json({ error: authentication.error }, { status: authentication.status })
38+
}
39+
const parsed = await parseRequest(confluenceSelectorPagesContract, request, {})
40+
if (!parsed.success) return parsed.response
41+
42+
const { credential, workflowId, domain: domainReference, title, limit } = parsed.data.body
43+
const resolution = await resolveAuthorizedSelectorContext(authentication.principal, {
44+
workflowId,
45+
credentialId: credential,
46+
context: { domain: domainReference },
47+
})
48+
if (!resolution.ok) {
49+
return NextResponse.json({ error: resolution.error }, { status: resolution.status })
50+
}
51+
52+
const credentialOwnerUserId = resolution.credentialAccess?.credentialOwnerUserId
53+
if (!credentialOwnerUserId) {
54+
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
55+
}
56+
const bundle = await resolveAtlassianSelectorCredential({
57+
credentialId: credential,
58+
credentialOwnerUserId,
59+
requestId: generateRequestId(),
60+
serviceId: 'confluence',
61+
})
62+
if (!bundle) {
63+
return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 })
64+
}
65+
66+
const domain = resolution.context.domain as string
67+
const cloudIdResolution = await resolveSelectorProviderValue('Confluence', async () =>
68+
bundle.cloudId
69+
? bundle.cloudId
70+
: getConfluenceCloudId(domain, bundle.accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS)
71+
)
72+
if (!cloudIdResolution.ok) {
73+
logger.warn('Confluence selector discovery failed', {
74+
status: cloudIdResolution.upstreamStatus ?? 'unknown',
75+
})
76+
return NextResponse.json(cloudIdResolution.failure, {
77+
status: cloudIdResolution.failure.status,
78+
})
79+
}
80+
const cloudId = cloudIdResolution.value
81+
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
82+
if (!cloudIdValidation.isValid) {
83+
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
84+
}
85+
86+
const search = new URLSearchParams({ limit: String(limit) })
87+
if (title) search.set('title', title)
88+
const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/pages?${search.toString()}`
89+
const response = await fetch(url, {
90+
method: 'GET',
91+
headers: { Accept: 'application/json', Authorization: `Bearer ${bundle.accessToken}` },
92+
})
93+
if (!response.ok) {
94+
logger.warn('Confluence selector pages request failed', { status: response.status })
95+
const failure = selectorProviderFailure('Confluence', response.status)
96+
return NextResponse.json(failure, { status: failure.status })
97+
}
98+
99+
const data = (await response.json()) as ConfluencePagesResponse
100+
return NextResponse.json({
101+
files: (data.results ?? []).map((page) => ({
102+
id: page.id,
103+
name: page.title,
104+
})),
105+
})
106+
} catch {
107+
logger.error('Error listing Confluence selector pages')
108+
return NextResponse.json({ error: 'Failed to retrieve Confluence pages' }, { status: 500 })
109+
}
110+
})

0 commit comments

Comments
 (0)