From 2279bf5bb7ed41c69a7c0543dd54056704a9a49c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 17 Sep 2026 00:49:44 -0700 Subject: [PATCH 1/2] fix(search): require explicit Calendar access failure reasons --- .../content/docs/search/google-calendar.mdx | 2 +- .../google-workspace/company-crawl.test.ts | 45 ++++++++++++------- .../google-workspace/company-crawl.ts | 1 + 3 files changed, 30 insertions(+), 18 deletions(-) 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-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] } From 4b10b396978b7a7796f15da992a30bb1dfd44d17 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 17 Sep 2026 00:54:58 -0700 Subject: [PATCH 2/2] fix(search): align Calendar connector regression coverage --- .../google-calendar/company-crawl.test.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) 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'], + }, ], }, })