diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts new file mode 100644 index 00000000..a243bfc2 --- /dev/null +++ b/src/__tests__/api.test.ts @@ -0,0 +1,36 @@ +import { AxiosError, AxiosHeaders } from 'axios'; + +import { isMissingScopeError } from '@lib/api'; + +const axiosErrorWithStatus = (status: number): AxiosError => { + const error = new AxiosError('request failed'); + error.response = { + status, + statusText: '', + data: {}, + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + return error; +}; + +describe('isMissingScopeError', () => { + it('treats 401 as the benign missing-scope degradation', () => { + expect(isMissingScopeError(axiosErrorWithStatus(401))).toBe(true); + }); + + it('treats 403 as the benign missing-scope degradation', () => { + expect(isMissingScopeError(axiosErrorWithStatus(403))).toBe(true); + }); + + it('does not swallow other HTTP errors', () => { + expect(isMissingScopeError(axiosErrorWithStatus(500))).toBe(false); + expect(isMissingScopeError(axiosErrorWithStatus(404))).toBe(false); + }); + + it('does not treat non-axios errors as missing-scope', () => { + expect(isMissingScopeError(new Error('boom'))).toBe(false); + expect(isMissingScopeError('boom')).toBe(false); + expect(isMissingScopeError(undefined)).toBe(false); + }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index ba478a82..c276f635 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -237,8 +237,9 @@ const IntegrationsResponseSchema = z.object({ /** * Check whether the project already has a Slack integration connected. * Requires the `integration:read` scope. Throws on failure — callers - * (including the SlackConnectScreen poll) decide how to degrade and - * are responsible for capturing the error exactly once. + * (including the SlackConnectScreen poll) decide how to degrade. A + * missing-scope 401/403 is an expected outcome, not a crash: use + * `isMissingScopeError` to tell it apart from failures worth capturing. */ export async function fetchSlackConnected( accessToken: string, @@ -261,6 +262,21 @@ export async function fetchSlackConnected( return parsed.data.results.some((i) => i.kind === 'slack'); } +/** + * True when an error from `fetchSlackConnected` is the expected, documented + * degradation: the access token lacks the `integration:read` scope, so the + * integrations endpoint answers 401/403. This is a benign "scope unavailable + * / treat as not connected" outcome (see `CONNECT_SLACK_SCOPE_ADDITIONS`), + * not a crash — callers fall back to the connect nudge and should NOT capture + * it as an exception. Genuinely unexpected failures (network, 5xx, parse) + * return false here and stay worth capturing. + */ +export function isMissingScopeError(error: unknown): boolean { + if (!axios.isAxiosError(error)) return false; + const status = error.response?.status; + return status === 401 || status === 403; +} + export function handleApiError(error: unknown, operation: string): ApiError { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<{ detail?: string }>; diff --git a/src/ui/tui/screens/SlackConnectScreen.tsx b/src/ui/tui/screens/SlackConnectScreen.tsx index 9db8f74d..cac6dbf3 100644 --- a/src/ui/tui/screens/SlackConnectScreen.tsx +++ b/src/ui/tui/screens/SlackConnectScreen.tsx @@ -31,7 +31,7 @@ import { Colors, Icons } from '@ui/tui/styles'; import { PickerMenu, LoadingBox } from '@ui/tui/primitives/index'; import { useKeyBindings, KeyMatch } from '@ui/tui/hooks/useKeyBindings'; import { getSlackAppCard } from '@lib/mcp-role-prompts'; -import { fetchSlackConnected } from '@lib/api'; +import { fetchSlackConnected, isMissingScopeError } from '@lib/api'; import { Program } from '@lib/programs/program-registry'; import { getOrAskForProjectData } from '@utils/setup-utils'; import { analytics } from '@utils/analytics'; @@ -155,17 +155,23 @@ export const SlackConnectScreen = ({ store }: SlackConnectScreenProps) => { }) .catch((err: unknown) => { if (cancelled) return; - // Capture once and stop polling — repeating a failing call - // every tick would spam error tracking. The nudge copy is - // the fallback either way; a failed check counts as not - // connected so the screen doesn't sit on the loading state. + // Stop polling either way — repeating a failing call every tick + // would spam. The nudge copy is the fallback; a failed check + // counts as not connected so the screen doesn't sit on the + // loading state. if (store.session.slackConnected === null) { store.setSlackConnected(false); } - analytics.captureException( - err instanceof Error ? err : new Error(String(err)), - { step: 'slack_connected_check' }, - ); + // A 401/403 is the documented degradation path: the token lacks + // `integration:read`, so the check can't run. That's expected and + // already handled by the nudge fallback — don't pollute error + // tracking with it. Capture only genuinely unexpected failures. + if (!isMissingScopeError(err)) { + analytics.captureException( + err instanceof Error ? err : new Error(String(err)), + { step: 'slack_connected_check' }, + ); + } }); }; check();