diff --git a/apps/docs/content/docs/search/google-calendar.mdx b/apps/docs/content/docs/search/google-calendar.mdx index d6fd93c15e2..817e7acf770 100644 --- a/apps/docs/content/docs/search/google-calendar.mdx +++ b/apps/docs/content/docs/search/google-calendar.mdx @@ -131,7 +131,7 @@ Sim indexes event titles, descriptions, times, locations, and the selected atten Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Status entries such as working location, out of office, focus time, and birthdays, and automatically generated reservation events from Gmail are not indexed. Events Google returns only as free/busy blocks, without searchable details, are not indexed. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing). -Search schedules syncs hourly. Event edits, cancellations, access changes, inactive or removed users, and events moving outside the date window are reconciled during completed background syncs. Central crawls page through each selected user and resume unfinished work before removing documents no longer listed. If an individual user's event listing returns a `403` with no reason or only `forbidden`, Sim records a warning and continues with the remaining users. The crawl stays incomplete and retries affected users on the next scheduled crawl; unread calendars are not treated as empty. Credential, delegation, Directory, and other provider failures still stop the crawl. The first sync may take longer, and results appear as indexing progresses; Search is not a live Calendar read. +Search schedules syncs hourly. Event edits, cancellations, access changes, inactive or removed users, and events moving outside the date window are reconciled during completed background syncs. Central crawls page through each selected user and resume unfinished work before removing documents no longer listed. If an individual user's event listing returns a `403` with an explicit `forbidden` reason and no other reasons, Sim records a warning and continues with the remaining users. The crawl stays incomplete and retries affected users on the next scheduled crawl; unread calendars are not treated as empty. A `403` without a reason stops the crawl because its cause is unknown. Credential, delegation, Directory, and other provider failures still stop the crawl. The first sync may take longer, and results appear as indexing progresses; Search is not a live Calendar read. ## Troubleshooting diff --git a/apps/sim/connectors/google-calendar/company-crawl.test.ts b/apps/sim/connectors/google-calendar/company-crawl.test.ts index 63ecd8afcb2..be9ecda4dea 100644 --- a/apps/sim/connectors/google-calendar/company-crawl.test.ts +++ b/apps/sim/connectors/google-calendar/company-crawl.test.ts @@ -354,8 +354,26 @@ describe('Google Calendar company crawl', () => { } ) - it('continues another user after an unclassified list access denial and retains its diagnostic', async () => { - fetchMock.mockResolvedValueOnce(response({ error: { code: 403 } }, 403)) + it.each([{ error: { code: 403 } }, { error: { code: 403, errors: [], details: [] } }])( + 'propagates an unclassified list access denial and retains its diagnostic: %j', + async (body) => { + fetchMock.mockResolvedValueOnce(response(body, 403)) + const syncContext = context() + await expect( + googleCalendarConnector.listDocuments('directory-token', {}, undefined, syncContext) + ).rejects.toMatchObject({ + status: 403, + diagnostic: { operation: 'calendar.events.list', reasons: [] }, + }) + expect(fetchMock).toHaveBeenCalledOnce() + expect(syncContext.getDelegatedAccessToken.mock.calls).toEqual([[ALICE.email]]) + } + ) + + it('continues another user after an explicit forbidden denial and retains its diagnostic', async () => { + fetchMock.mockResolvedValueOnce( + response({ error: { code: 403, errors: [{ reason: 'forbidden' }] } }, 403) + ) const first = await googleCalendarConnector.listDocuments( 'directory-token', {}, @@ -369,7 +387,12 @@ describe('Google Calendar company crawl', () => { listingFailures: { count: 1, samples: [ - { scope: ALICE.email, operation: 'calendar.events.list', status: 403, reasons: [] }, + { + scope: ALICE.email, + operation: 'calendar.events.list', + status: 403, + reasons: ['forbidden'], + }, ], }, }) diff --git a/apps/sim/connectors/google-workspace/company-crawl.test.ts b/apps/sim/connectors/google-workspace/company-crawl.test.ts index 85456985800..0c8c5c16041 100644 --- a/apps/sim/connectors/google-workspace/company-crawl.test.ts +++ b/apps/sim/connectors/google-workspace/company-crawl.test.ts @@ -1,6 +1,6 @@ /** @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { GoogleApiError } from '@/connectors/google-workspace/api-errors' +import { GoogleApiError, readGoogleApiError } from '@/connectors/google-workspace/api-errors' import { getGoogleWorkspaceDocument, InvalidGoogleWorkspaceCursor, @@ -501,26 +501,37 @@ describe('Google Workspace per-user central crawl', () => { expect((await list(context(), undefined, CONFIG, 'google_calendar')).documents).toHaveLength(1) }) - it.each([{ reasons: [] }, { reasons: ['forbidden'] }])( - 'isolates Calendar list access failures without claiming a disabled service (%j)', - async ({ reasons }) => { - listUserDocuments.mockRejectedValueOnce( - new GoogleApiError('calendar.events.list', 403, reasons) - ) - const first = await list(context(), undefined, CONFIG, 'google_calendar') - expect(first.listingFailures?.samples[0]).toEqual({ - scope: 'alice@corp.com', - operation: 'calendar.events.list', - status: 403, - reasons, - }) - const second = await list(context(), first.nextCursor, CONFIG, 'google_calendar') - expect(second.documents[0].acl).toEqual(['u:bob@corp.com']) - expect(second.reconciliationSafe).toBe(false) + it('isolates explicit Calendar list access failures without claiming a disabled service', async () => { + listUserDocuments.mockRejectedValueOnce( + new GoogleApiError('calendar.events.list', 403, ['forbidden']) + ) + const first = await list(context(), undefined, CONFIG, 'google_calendar') + expect(first.listingFailures?.samples[0]).toEqual({ + scope: 'alice@corp.com', + operation: 'calendar.events.list', + status: 403, + reasons: ['forbidden'], + }) + const second = await list(context(), first.nextCursor, CONFIG, 'google_calendar') + expect(second.documents[0].acl).toEqual(['u:bob@corp.com']) + expect(second.reconciliationSafe).toBe(false) + }) + + it.each([{ error: { code: 403 } }, { error: { code: 403, errors: [], details: [] } }])( + 'propagates a Calendar 403 without reason codes: %j', + async (body) => { + const error = await readGoogleApiError(json(body, 403), 'calendar.events.list') + listUserDocuments.mockRejectedValueOnce(error) + const ctx: Record = context() + + await expect(list(ctx, undefined, CONFIG, 'google_calendar')).rejects.toBe(error) + expect(ctx.reconciliationUnsafe).toBeUndefined() + expect(listUserDocuments).toHaveBeenCalledOnce() } ) it.each([ + [403, []], [403, ['rateLimitExceeded']], [403, ['userRateLimitExceeded']], [403, ['quotaExceeded']], diff --git a/apps/sim/connectors/google-workspace/company-crawl.ts b/apps/sim/connectors/google-workspace/company-crawl.ts index f0a07992f8e..3a354ad36f3 100644 --- a/apps/sim/connectors/google-workspace/company-crawl.ts +++ b/apps/sim/connectors/google-workspace/company-crawl.ts @@ -153,6 +153,7 @@ function userListingFailure( reasons.every((reason) => reason === 'failedPrecondition') : error.diagnostic.operation === 'calendar.events.list' && error.status === 403 && + reasons.length > 0 && reasons.every((reason) => reason === 'forbidden') return isolated ? { operation: error.diagnostic.operation, status: error.status, reasons: [...reasons] }