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
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image'
Use an Atlassian service account for Jira, Jira Service Management, and Confluence workflows. One credential can serve all three products on the same site when its account access and token scopes cover each product.

<Callout type="info">
Setting up Search? Follow the [Confluence Search service-account guide](/search/confluence#using-a-service-account) for its content and permission scopes. [Jira Search](/search/jira) uses each teammate's OAuth account; a workflow service account does not replace that connection.
Setting up a Confluence knowledge base connector? Use the [connector scope list](#confluence-knowledge-base-connectors). For Search, follow the [Confluence Search service-account guide](/search/confluence#using-a-service-account). [Jira Search](/search/jira) uses each teammate's OAuth account; a workflow service account does not replace that connection.
</Callout>

## Create the account and token
Expand Down Expand Up @@ -56,6 +56,28 @@ read:page:confluence

`read:confluence-user` covers the [current-user check](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-users/#api-wiki-rest-api-user-current-get). The [space picker](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/#api-spaces-get) needs `read:space:confluence`; page reads and the page picker need `read:page:confluence`.

### Confluence knowledge base connectors

A Confluence knowledge base connector reads pages, blog posts, attachments, labels, and the space permissions and page restrictions it mirrors. Its service-account token needs every scope below, not only the Confluence scopes above:

```text
read:confluence-content.all
read:page:confluence
read:blogpost:confluence
read:attachment:confluence
read:space:confluence
read:label:confluence
search:confluence
read:confluence-space.summary
read:content.metadata:confluence
read:space.permission:confluence
read:confluence-user
read:user:confluence
read:group:confluence
```

Without `read:attachment:confluence`, pages still sync, but their PDF, Word, Excel, and PowerPoint attachments are not indexed and each sync reports a partial source listing. To fix a token that is missing scopes, create a replacement token with the full list. A credential admin then opens the credential in **Integrations**, selects **Reconnect**, and enters the new token and site domain. Connectors that use the credential keep using it.

### Workflow actions

Add scopes for the actions your workflow performs:
Expand Down
128 changes: 128 additions & 0 deletions apps/sim/connectors/confluence/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getAllMockLoggers } from '@sim/testing/mocks/logger.mock'
import JSZip from 'jszip'
import { PDFDocument, StandardFonts } from 'pdf-lib'
import { beforeEach, describe, expect, it, vi } from 'vitest'
Expand Down Expand Up @@ -249,6 +250,133 @@ describe('Confluence attachment listing', () => {
}
)

describe('server errors', () => {
const parents = (ids: string[]) =>
vi.fn(
async (): Promise<ExternalDocumentList> => ({
documents: ids.map((id) => parent(id)),
hasMore: false,
})
)

/** Answers a parent's attachment listing with `status` while `failing` says so. */
function listingFails(failing: (parentId: string) => boolean, status = 500) {
fetchMock.mockImplementation(async (input) => {
const parentId = new URL(String(input)).pathname.split('/').at(-2) ?? ''
if (failing(parentId)) return new Response('upstream error', { status })
return Response.json({ results: [file({ id: `${parentId}-file`, pageId: parentId })] })
})
}

/** Runs a listing past the shared backoff (~31 s for five retries) without real waits. */
async function listPastBackoff(input: Parameters<typeof listConfluenceAttachments>[0]) {
vi.useFakeTimers()
try {
const listing = listConfluenceAttachments(input)
const settled = listing.then(
() => undefined,
() => undefined
)
for (let i = 0; i < 20; i++) await vi.advanceTimersByTimeAsync(10_000)
await settled
return await listing
} finally {
vi.useRealTimers()
}
}

it.each([
['a complete body', () => 'upstream error'],
['a body that never finishes', () => new ReadableStream<Uint8Array>()],
[
'a body stream that already errored',
() =>
new ReadableStream<Uint8Array>({
start: (controller) => controller.error(new Error('connection reset')),
}),
],
])('retries a transient 500 with %s instead of failing the listing', async (_, body) => {
let failed = false
fetchMock.mockImplementation(async () => {
if (failed) return Response.json({ results: [file({ id: 'p1-file' })] })
failed = true
return new Response(body(), { status: 500 })
})
const result = await listPastBackoff({ ...INPUT, listParents: parents(['p1']) })
expect(result.documents.map((doc) => doc.externalId)).toEqual([
'p1',
'attachment:page:p1:p1-file',
])
expect(result.listingFailures).toBeUndefined()
})

it('skips isolated parents whose listing keeps failing and keeps listing the rest', async () => {
listingFails((id) => id === 'p1' || id === 'p3' || id === 'p5')
const context: Record<string, unknown> = {}
const result = await listPastBackoff({
...INPUT,
listParents: parents(['p1', 'p2', 'p3', 'p4', 'p5']),
syncContext: context,
})
expect(result.documents.map((doc) => doc.externalId)).toEqual([
'p1',
'p2',
'p3',
'p4',
'p5',
'attachment:page:p2:p2-file',
'attachment:page:p4:p4-file',
])
expect(result.listingFailures).toEqual({
count: 3,
samples: ['p1', 'p3', 'p5'].map((scope) => ({
scope,
operation: 'confluence.attachments.list',
status: 500,
reasons: ['attachment_listing_unavailable'],
})),
})
expect(result.reconciliationSafe).toBe(false)
expect(context.reconciliationUnsafe).toBe(true)
})

it('logs Atlassian trace identifiers for a 500 but never its body', async () => {
let failed = false
fetchMock.mockImplementation(async () => {
if (failed) return Response.json({ results: [] })
failed = true
return Response.json(
{
errors: [{ code: 'INTERNAL_SERVER_ERROR', title: 'x', detail: 'leaked-secret-value' }],
},
{
status: 500,
headers: { 'atl-traceid': '5c1f0e2a9b7d4e1f', 'x-arequestid': 'not an id; <script>' },
}
)
})
await listPastBackoff({ ...INPUT, listParents: parents(['p1']) })
const logged = JSON.stringify(
getAllMockLoggers().flatMap((logger) =>
[logger.info, logger.warn, logger.error, logger.debug].flatMap((fn) => fn.mock.calls)
)
)
expect(logged).toContain('5c1f0e2a9b7d4e1f')
expect(logged).not.toContain('<script>')
expect(logged).not.toContain('leaked-secret-value')
})

it.each([500, 503])(
'fails the sync when consecutive parents answer %s, as during an outage',
async (status) => {
listingFails(() => true, status)
await expect(
listPastBackoff({ ...INPUT, listParents: parents(['p1', 'p2', 'p3', 'p4']) })
).rejects.toMatchObject({ status })
}
)
})

it('surfaces known oversized files as skipped without downloading', async () => {
fetchMock.mockResolvedValue(
Response.json({ results: [file({ fileSize: CONNECTOR_MAX_FILE_BYTES + 1 })] })
Expand Down
Loading
Loading