Skip to content
Closed
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
182 changes: 182 additions & 0 deletions apps/sim/app/api/tools/atlassian-server-resolved-selectors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
resolveContext: vi.fn(),
resolveAtlassianCredential: vi.fn(),
}))

vi.mock('@/lib/selectors/server/resolve-authorized-context', () => ({
authenticateSelectorRequest: mocks.authenticate,
resolveAuthorizedSelectorContext: mocks.resolveContext,
}))
vi.mock('@/lib/selectors/application/atlassian-credential', () => ({
resolveAtlassianSelectorCredential: mocks.resolveAtlassianCredential,
}))

import { POST as confluencePages } from '@/app/api/tools/confluence/selector-pages/route'
import { POST as jiraProject } from '@/app/api/tools/jira/projects/route'

const principal = {
kind: 'session',
userId: 'viewer-1',
sessionId: 'session-1',
} as const

function request(path: string, body: unknown) {
return createMockRequest(
'POST',
body,
{ 'content-type': 'application/json' },
`http://localhost:3000${path}`
)
}

describe('server-resolved Atlassian selector routes', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
mocks.authenticate.mockResolvedValue({ ok: true, principal })
mocks.resolveContext.mockImplementation(
async (_principal: unknown, input: { context: Record<string, unknown> }) => ({
ok: true,
context: { ...input.context, domain: 'resolved-secret.example.com' },
requesterUserId: 'viewer-1',
workspaceId: 'workspace-1',
credentialAccess: { credentialOwnerUserId: 'owner-1' },
})
)
mocks.resolveAtlassianCredential.mockResolvedValue({
accessToken: 'atlassian-token',
cloudId: 'cloud-id-1',
})
})

it('authenticates before parsing a malformed request', async () => {
mocks.authenticate.mockResolvedValue({ ok: false, status: 401, error: 'Unauthorized' })

const response = await jiraProject(
request('/api/tools/jira/projects', { definitely: 'not a Jira selector request' })
)

expect(response.status).toBe(401)
expect(await response.json()).toEqual({ error: 'Unauthorized' })
expect(mocks.resolveContext).not.toHaveBeenCalled()
})

it.each([
{
name: 'Jira Project',
route: jiraProject,
path: '/api/tools/jira/projects',
body: {
credential: 'credential-1',
workflowId: 'workflow-1',
domain: '{{INACCESSIBLE_SECRET}}',
projectId: 'SIM',
},
},
{
name: 'Confluence Page',
route: confluencePages,
path: '/api/tools/confluence/selector-pages',
body: {
credential: 'credential-1',
workflowId: 'workflow-1',
domain: '{{INACCESSIBLE_SECRET}}',
},
},
])('$name stops inaccessible references before provider access', async (testCase) => {
mocks.resolveContext.mockResolvedValue({
ok: false,
status: 400,
error: 'Unable to resolve selector configuration',
})
const providerFetch = vi.fn()
vi.stubGlobal('fetch', providerFetch)

const response = await testCase.route(request(testCase.path, testCase.body))

expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Unable to resolve selector configuration' })
expect(mocks.resolveAtlassianCredential).not.toHaveBeenCalled()
expect(providerFetch).not.toHaveBeenCalled()
})

it('maps Jira projects without exposing resolved provider data', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
Response.json({ id: '10001', name: 'Sim', self: 'https://resolved-secret.example.com' })
)
)

const response = await jiraProject(
request('/api/tools/jira/projects', {
credential: 'credential-1',
workflowId: 'workflow-1',
domain: '{{DOMAIN}}',
projectId: 'SIM',
})
)

expect(await response.json()).toEqual({ project: { id: '10001', name: 'Sim' } })
})

it('maps Confluence pages without exposing resolved provider data', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
Response.json({
results: [
{
id: '20001',
title: 'Runbook',
_links: { webui: 'https://resolved-secret.example.com/wiki/runbook' },
},
],
})
)
)

const response = await confluencePages(
request('/api/tools/confluence/selector-pages', {
credential: 'credential-1',
workflowId: 'workflow-1',
domain: '{{DOMAIN}}',
})
)

expect(await response.json()).toEqual({ files: [{ id: '20001', name: 'Runbook' }] })
})

it('maps provider failures to a stable public response without reading their body', async () => {
const providerText = vi.fn().mockResolvedValue('provider-body-secret-marker')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 418, text: providerText })
)

const response = await jiraProject(
request('/api/tools/jira/projects', {
credential: 'credential-1',
workflowId: 'workflow-1',
domain: '{{DOMAIN}}',
projectId: 'SIM',
})
)

expect(response.status).toBe(502)
expect(await response.json()).toEqual({
error: 'Jira selector discovery failed.',
status: 502,
})
expect(providerText).not.toHaveBeenCalled()
})
})
99 changes: 99 additions & 0 deletions apps/sim/app/api/tools/confluence/selector-page/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceSelectorPageContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveAtlassianSelectorCredential } from '@/lib/selectors/application/atlassian-credential'
import {
resolveSelectorProviderValue,
SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS,
selectorProviderFailure,
} from '@/lib/selectors/server/provider-errors'
import {
authenticateSelectorRequest,
resolveAuthorizedSelectorContext,
} from '@/lib/selectors/server/resolve-authorized-context'
import { getConfluenceCloudId } from '@/tools/confluence/utils'

const logger = createLogger('ConfluenceSelectorPageAPI')

export const dynamic = 'force-dynamic'

interface ConfluencePageResponse {
id: string
title: string
}

export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const authentication = await authenticateSelectorRequest(request)
if (!authentication.ok) {
return NextResponse.json({ error: authentication.error }, { status: authentication.status })
}
const parsed = await parseRequest(confluenceSelectorPageContract, request, {})
if (!parsed.success) return parsed.response

const { credential, workflowId, domain: domainReference, pageId } = parsed.data.body
const resolution = await resolveAuthorizedSelectorContext(authentication.principal, {
workflowId,
credentialId: credential,
context: { domain: domainReference },
})
if (!resolution.ok) {
return NextResponse.json({ error: resolution.error }, { status: resolution.status })
}

const credentialOwnerUserId = resolution.credentialAccess?.credentialOwnerUserId
if (!credentialOwnerUserId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const bundle = await resolveAtlassianSelectorCredential({
credentialId: credential,
credentialOwnerUserId,
requestId: generateRequestId(),
serviceId: 'confluence',
})
if (!bundle) {
return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 })
}

const domain = resolution.context.domain as string
const cloudIdResolution = await resolveSelectorProviderValue('Confluence', async () =>
bundle.cloudId
? bundle.cloudId
: getConfluenceCloudId(domain, bundle.accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS)
)
if (!cloudIdResolution.ok) {
logger.warn('Confluence selector discovery failed', {
status: cloudIdResolution.upstreamStatus ?? 'unknown',
})
return NextResponse.json(cloudIdResolution.failure, {
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
status: cloudIdResolution.failure.status,
})
}
const cloudId = cloudIdResolution.value
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}

const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/pages/${pageId}`
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json', Authorization: `Bearer ${bundle.accessToken}` },
})
if (!response.ok) {
logger.warn('Confluence selector page request failed', { status: response.status })
const failure = selectorProviderFailure('Confluence', response.status)
return NextResponse.json(failure, { status: failure.status })
}

const page = (await response.json()) as ConfluencePageResponse
return NextResponse.json({ id: page.id, title: page.title })
} catch {
logger.error('Error retrieving Confluence selector page')
return NextResponse.json({ error: 'Failed to retrieve Confluence page' }, { status: 500 })
}
})
110 changes: 110 additions & 0 deletions apps/sim/app/api/tools/confluence/selector-pages/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceSelectorPagesContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveAtlassianSelectorCredential } from '@/lib/selectors/application/atlassian-credential'
import {
resolveSelectorProviderValue,
SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS,
selectorProviderFailure,
} from '@/lib/selectors/server/provider-errors'
import {
authenticateSelectorRequest,
resolveAuthorizedSelectorContext,
} from '@/lib/selectors/server/resolve-authorized-context'
import { getConfluenceCloudId } from '@/tools/confluence/utils'

const logger = createLogger('ConfluenceSelectorPagesAPI')

export const dynamic = 'force-dynamic'

interface ConfluencePageRow {
id: string
title: string
}

interface ConfluencePagesResponse {
results?: ConfluencePageRow[]
}

export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const authentication = await authenticateSelectorRequest(request)
if (!authentication.ok) {
return NextResponse.json({ error: authentication.error }, { status: authentication.status })
}
const parsed = await parseRequest(confluenceSelectorPagesContract, request, {})
if (!parsed.success) return parsed.response

const { credential, workflowId, domain: domainReference, title, limit } = parsed.data.body
const resolution = await resolveAuthorizedSelectorContext(authentication.principal, {
workflowId,
credentialId: credential,
context: { domain: domainReference },
})
if (!resolution.ok) {
return NextResponse.json({ error: resolution.error }, { status: resolution.status })
}

const credentialOwnerUserId = resolution.credentialAccess?.credentialOwnerUserId
if (!credentialOwnerUserId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const bundle = await resolveAtlassianSelectorCredential({
credentialId: credential,
credentialOwnerUserId,
requestId: generateRequestId(),
serviceId: 'confluence',
})
if (!bundle) {
return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 })
}

const domain = resolution.context.domain as string
const cloudIdResolution = await resolveSelectorProviderValue('Confluence', async () =>
bundle.cloudId
? bundle.cloudId
: getConfluenceCloudId(domain, bundle.accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS)
)
if (!cloudIdResolution.ok) {
logger.warn('Confluence selector discovery failed', {
status: cloudIdResolution.upstreamStatus ?? 'unknown',
})
return NextResponse.json(cloudIdResolution.failure, {
status: cloudIdResolution.failure.status,
})
}
const cloudId = cloudIdResolution.value
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}

const search = new URLSearchParams({ limit: String(limit) })
if (title) search.set('title', title)
const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/pages?${search.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json', Authorization: `Bearer ${bundle.accessToken}` },
})
if (!response.ok) {
logger.warn('Confluence selector pages request failed', { status: response.status })
const failure = selectorProviderFailure('Confluence', response.status)
return NextResponse.json(failure, { status: failure.status })
}

const data = (await response.json()) as ConfluencePagesResponse
return NextResponse.json({
files: (data.results ?? []).map((page) => ({
id: page.id,
name: page.title,
})),
})
} catch {
logger.error('Error listing Confluence selector pages')
return NextResponse.json({ error: 'Failed to retrieve Confluence pages' }, { status: 500 })
}
})
Loading
Loading