diff --git a/src/lib/__tests__/api.test.ts b/src/lib/__tests__/api.test.ts new file mode 100644 index 00000000..b587a5dc --- /dev/null +++ b/src/lib/__tests__/api.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { AxiosError, AxiosHeaders } from 'axios'; +import { isAuthOrScopeError } from '@lib/api'; + +/** + * `isAuthOrScopeError` gates whether a caller reports a failure to error + * tracking. The Slack connect poll 403s when the token lacks + * `integration:read` — an expected, gracefully-handled outcome that must + * NOT be captured as a bug. Only genuinely unexpected failures should be. + */ +const axiosErrorWithStatus = (status: number): AxiosError => { + const error = new AxiosError('request failed'); + error.response = { + status, + statusText: '', + data: {}, + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + return error; +}; + +describe('isAuthOrScopeError', () => { + it('treats 401 (unauthenticated) as an auth/scope error', () => { + expect(isAuthOrScopeError(axiosErrorWithStatus(401))).toBe(true); + }); + + it('treats 403 (missing scope) as an auth/scope error', () => { + expect(isAuthOrScopeError(axiosErrorWithStatus(403))).toBe(true); + }); + + it('does not treat other HTTP failures as auth/scope errors', () => { + expect(isAuthOrScopeError(axiosErrorWithStatus(404))).toBe(false); + expect(isAuthOrScopeError(axiosErrorWithStatus(500))).toBe(false); + }); + + it('does not treat non-axios errors as auth/scope errors', () => { + expect(isAuthOrScopeError(new Error('boom'))).toBe(false); + expect(isAuthOrScopeError('boom')).toBe(false); + expect(isAuthOrScopeError(undefined)).toBe(false); + }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index ba478a82..553169bc 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -261,6 +261,20 @@ export async function fetchSlackConnected( return parsed.data.results.some((i) => i.kind === 'slack'); } +/** + * True for the auth/scope failures that mean "the token can't read this", + * not "something is broken": 401 (unauthenticated) and 403 (authenticated + * but missing the required scope). Callers that already degrade gracefully + * on these — e.g. the Slack connect poll, which 403s without the + * `integration:read` scope (see CONNECT_SLACK_SCOPE_ADDITIONS) — use this to + * skip error-tracking reports for an expected, handled outcome. + */ +export function isAuthOrScopeError(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..ecfff892 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, isAuthOrScopeError } 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 error tracking, and 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 here is expected and already handled: the token just + // lacks `integration:read`, so we can't tell if Slack is connected + // and fall back to the nudge (see CONNECT_SLACK_SCOPE_ADDITIONS). + // Only report genuinely unexpected failures to error tracking. + if (!isAuthOrScopeError(err)) { + analytics.captureException( + err instanceof Error ? err : new Error(String(err)), + { step: 'slack_connected_check' }, + ); + } }); }; check();