diff --git a/apps/docs/content/docs/integrations/atlassian-service-account.mdx b/apps/docs/content/docs/integrations/atlassian-service-account.mdx index cdd139e837c..4a8aee1dd2e 100644 --- a/apps/docs/content/docs/integrations/atlassian-service-account.mdx +++ b/apps/docs/content/docs/integrations/atlassian-service-account.mdx @@ -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. -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. ## Create the account and token @@ -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: diff --git a/apps/sim/connectors/confluence/attachments.test.ts b/apps/sim/connectors/confluence/attachments.test.ts index dcff9b8117e..86a5e00915b 100644 --- a/apps/sim/connectors/confluence/attachments.test.ts +++ b/apps/sim/connectors/confluence/attachments.test.ts @@ -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' @@ -249,6 +250,133 @@ describe('Confluence attachment listing', () => { } ) + describe('server errors', () => { + const parents = (ids: string[]) => + vi.fn( + async (): Promise => ({ + 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[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()], + [ + 'a body stream that already errored', + () => + new ReadableStream({ + 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 = {} + 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;