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
2 changes: 1 addition & 1 deletion apps/docs/content/docs/search/confluence.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ In **Sync history**, **Continuing** means a healthy listing needs another batch.
| Space picker is empty or fails | Check the domain, account's space access, and `read:space:confluence` scope. Manual space keys are also supported. |
| Service-account validation fails | Check token expiry, site, Confluence app access, and the full scope list above, including `read:confluence-user`. |
| Content syncs but Search is empty | Connect your personal Confluence identity. Check permission/directory sync errors and group-read scopes. |
| **Some permissions could not be verified** | Open the source's **Sync history**. Check the service account's space, page, and directory access. If access is correct and the warning persists, ask your operator to inspect the connector run's permission errors. Do not broaden sharing to clear the warning. |
| **Permission verification incomplete** | Open the source's **Sync history**. Check the service account's space, page, and directory access. If access is correct and the warning persists, ask your operator to inspect the connector run's permission errors, including incomplete or repeated permission pages. Documents without verified access stay hidden; do not broaden sharing to clear the warning. |
| A new page, blog post, or label is missing | Confluence search can take time to update. Once the content appears in Confluence search with the selected label, sync again. |
| A restricted page is missing | Both your account and the crawling account need access to the page and its ancestors. |
| Embedded content is missing | Index the referenced page separately; remote macro output is excluded. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiClientError } from '@/lib/api/client/errors'
import type { ConnectorData } from '@/lib/api/contracts/knowledge/connectors'
import { SOURCE_PERMISSION_ERROR } from '@/lib/knowledge/connectors/sync-limits'
import type { ConnectorActionsOptions } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions'

const mocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -257,6 +258,40 @@ describe('organization source detail navigation', () => {
)
})

it.each(['active', 'pending', 'syncing'] as const)(
'keeps the safe permission warning visible while a source is %s',
async (status) => {
mocks.detail.mockReturnValue({
data: { ...connector, accessMode: 'admin', status, lastSyncError: SOURCE_PERMISSION_ERROR },
})
await render()
expect(container.textContent).toContain('Permission verification incomplete')
expect(container.textContent).toContain(SOURCE_PERMISSION_ERROR)
expect(container.textContent).not.toContain(
'Review the connection settings and try syncing again.'
)
}
)

it.each(['idle', 'pending', 'running'] as const)(
'preserves a connector permission warning alongside a member error while %s',
async (memberSyncStatus) => {
mocks.detail.mockReturnValue({
data: {
...connector,
accessMode: 'members',
memberSyncStatus,
lastSyncError: SOURCE_PERMISSION_ERROR,
lastMemberSyncError: 'Private member error details',
},
})
await render()
expect(container.textContent).toContain('Permission verification incomplete')
expect(container.textContent).toContain(SOURCE_PERMISSION_ERROR)
expect(container.textContent).not.toContain('Private member error details')
}
)

it.each(['', '?view=settings', '?view=history'])(
'shows integration deactivation independently of source sync state at %s',
async (searchParams) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsa
import { isApiClientError } from '@/lib/api/client/errors'
import type { ConnectorData, ConnectorDetailData } from '@/lib/api/contracts/knowledge/connectors'
import type { ResourceScope } from '@/lib/core/resource-scope'
import { SOURCE_PERMISSION_ERROR } from '@/lib/knowledge/connectors/sync-limits'
import { organizationRoutes } from '@/lib/navigation/paths'
import { describeSearchSource } from '@/lib/sim-search/source-identity'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
Expand Down Expand Up @@ -186,6 +187,7 @@ function SourceDetailContent({
? describeSearchSource(meta, connector.sourceConfig) || meta.name
: 'Connection'
const { effectiveStatus, lastSyncError } = getConnectorSyncState(connector)
const permissionsIncomplete = connector.lastSyncError === SOURCE_PERMISSION_ERROR
const status =
effectiveStatus === 'paused'
? 'Sync paused'
Expand Down Expand Up @@ -271,12 +273,23 @@ function SourceDetailContent({
>
{integrationFeedback}
<SourceNavigation view={view} onViewChange={onViewChange} />
{effectiveStatus === 'active' && lastSyncError && (
<SettingsResourceRow
title='Some connection updates are incomplete'
description='Review the connection settings and try syncing again.'
/>
)}
{lastSyncError &&
(effectiveStatus === 'active' ||
(permissionsIncomplete &&
(effectiveStatus === 'pending' || effectiveStatus === 'syncing'))) && (
<SettingsResourceRow
title={
permissionsIncomplete
? 'Permission verification incomplete'
: 'Some connection updates are incomplete'
}
description={
permissionsIncomplete
? SOURCE_PERMISSION_ERROR
: 'Review the connection settings and try syncing again.'
}
/>
)}
<ConnectorRecovery
connector={connector}
knowledgeBaseId={connector.knowledgeBaseId}
Expand Down
140 changes: 132 additions & 8 deletions apps/sim/connectors/confluence/permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_ACL_TOKENS } from '@/lib/knowledge/access/tokens'
import {
getReadRestriction,
listAncestorIds,
Expand Down Expand Up @@ -292,20 +293,22 @@ describe('listSpaceReadPrincipals', () => {
'rejects a repeated or missing cursor without publishing partial permissions: %s',
async (next) => {
mockFetch
.mockResolvedValueOnce(jsonResponse({ results: [], _links: { next: '?cursor=next' } }))
.mockResolvedValueOnce(jsonResponse({ results: [], _links: { next } }))
.mockResolvedValueOnce(
jsonResponse({ results: [{ id: 'first' }], _links: { next: '?cursor=next' } })
)
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 'second' }], _links: { next } }))

await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow(
'invalid or repeated space permissions continuation'
'invalid or repeated permission continuation'
)
expect(mockFetch).toHaveBeenCalledTimes(2)
}
)

it('rejects a cursor cycle rather than making a hundred repeated requests', async () => {
for (const cursor of ['first', 'second', 'first']) {
for (const [page, cursor] of ['first', 'second', 'first'].entries()) {
mockFetch.mockResolvedValueOnce(
jsonResponse({ results: [], _links: { next: `?cursor=${cursor}` } })
jsonResponse({ results: [{ id: String(page) }], _links: { next: `?cursor=${cursor}` } })
)
}
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow('repeated')
Expand All @@ -315,7 +318,7 @@ describe('listSpaceReadPrincipals', () => {
it('rejects a malformed collection instead of treating it as a verified empty grant', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({}))
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow(
'invalid space permissions'
'invalid permission page'
)
})

Expand All @@ -325,6 +328,7 @@ describe('listSpaceReadPrincipals', () => {
jsonResponse({
results: [
{
id: String(page),
principal: { type: 'user', id: 'reader' },
operation: { key: 'read', targetType: 'space' },
},
Expand All @@ -333,9 +337,129 @@ describe('listSpaceReadPrincipals', () => {
})
)
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow(
'exceeded 100 pages (100 entries)'
'exceeded 1000 pages (1000 entries)'
)
expect(mockFetch).toHaveBeenCalledTimes(100)
expect(mockFetch).toHaveBeenCalledTimes(1000)
})

it('reads past 25,000 assignments and only publishes readers after the final page', async () => {
let page = 0
mockFetch.mockImplementation(async () => {
const current = page++
return jsonResponse({
results:
current < 100
? Array.from({ length: 250 }, (_, index) => ({
id: `${current}-${index}`,
principal: { type: 'user', id: 'editor' },
operation: { key: 'create', targetType: 'page' },
}))
: [
{
id: 'last',
principal: { type: 'group', id: 'readers' },
operation: { key: 'read', targetType: 'space' },
},
],
_links: current < 100 ? { next: `?cursor=${page}` } : {},
})
})
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'large-space')).resolves.toEqual([
{ kind: 'group', id: 'readers' },
])
expect(mockFetch).toHaveBeenCalledTimes(101)
})

it('rejects repeated assignments even when cursors and record order change', async () => {
const reader = {
id: 'one',
principal: { type: 'user', id: 'reader' },
operation: { key: 'read', targetType: 'space' },
}
mockFetch
.mockResolvedValueOnce(
jsonResponse({ results: [reader, { id: 'two' }], _links: { next: '?cursor=one' } })
)
.mockResolvedValueOnce(
jsonResponse({ results: [{ id: 'two' }, reader], _links: { next: '?cursor=two' } })
)
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space')).rejects.toThrow(
'repeated a permission page'
)
expect(mockFetch).toHaveBeenCalledTimes(2)
})

it('follows an empty permission page that has a continuation', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ results: [], _links: { next: '?cursor=next' } }))
mockFetch.mockResolvedValueOnce(
jsonResponse({
results: [
{
principal: { type: 'group', id: 'readers' },
operation: { key: 'read', targetType: 'space' },
},
],
})
)
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space')).resolves.toEqual([
{ kind: 'group', id: 'readers' },
])
expect(mockFetch).toHaveBeenCalledTimes(2)
})

it('bounds retained readers without truncating a large grant', async () => {
let page = 0
mockFetch.mockImplementation(async () =>
jsonResponse({
results: Array.from({ length: 250 }, (_, index) => ({
principal: { type: 'user', id: `reader-${page}-${index}` },
operation: { key: 'read', targetType: 'space' },
})),
_links: { next: `?cursor=${++page}` },
})
)
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space')).rejects.toThrow(
'document permission limit'
)
expect(mockFetch).toHaveBeenCalledTimes(Math.floor(MAX_ACL_TOKENS / 250) + 1)
})

it('rejects an oversized permission response before accepting its readers', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ results: [{ id: 'x'.repeat(1024 * 1024) }] }))
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space')).rejects.toThrow(
'exceeds maximum size'
)
})

it('does not return a partial reader list when a later permission page fails', async () => {
mockFetch
.mockResolvedValueOnce(
jsonResponse({
results: [
{
principal: { type: 'user', id: 'reader' },
operation: { key: 'read', targetType: 'space' },
},
],
_links: { next: '?cursor=next' },
})
)
.mockResolvedValueOnce(jsonResponse({}, 403))
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space')).rejects.toThrow('403')
expect(mockFetch).toHaveBeenCalledTimes(2)
})

it('refuses an oversized continuation before issuing another request', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({
results: [],
_links: { next: `?cursor=${'x'.repeat(8193)}` },
})
)
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space')).rejects.toThrow(
'invalid or repeated permission continuation'
)
expect(mockFetch).toHaveBeenCalledTimes(1)
})
})

Expand Down
Loading
Loading