diff --git a/doc/observability.md b/doc/observability.md index ac5a5d22924..9664f99c24d 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -483,6 +483,10 @@ These fields contain build identifiers; they add no tenant or user identity. refreshing its catalog returns 422 with `oauth_challenge` and the existing setup/reconnect links. Other upstream failures still return 502 and are reported, including an unexplained upstream HTTP 400. +- Expired OAuth credentials without a refresh token, or a rejected refresh token + that requires reauthorization. Discovery and health checks return 422 with + `oauth_refresh_missing` or `oauth_reauthorization_required` and the existing + reconnect instructions. Unexpected refresh failures remain reportable. - Slack's explicit response that its app has not enabled MCP access. Discovery and health checks return 422 with `slack_mcp_access_disabled` and setup instructions. This requires Slack's exact MCP endpoint and known error; diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 7ec03d1c13b..bd7a2c6d5fa 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -11973,6 +11973,7 @@ describeEmbeddedPostgres("tool access service", () => { details: expect.objectContaining({ code: "oauth_reauthorization_required", }), + status: 422, }, ); } @@ -12245,7 +12246,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(JSON.stringify(updated.config)).not.toContain("m2m-access-token"); }); - it("fails expired OAuth credentials without a refresh token and returns reconnect links", async () => { + it.each(["catalog", "catalog/refresh", "health-check"])("returns reconnect instructions on %s for expired OAuth without a refresh token", async (path) => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", @@ -12328,7 +12329,7 @@ describeEmbeddedPostgres("tool access service", () => { actorId: "board", }), ).rejects.toMatchObject({ - status: 502, + status: 422, details: expect.objectContaining({ code: "oauth_refresh_missing", setupUrl: `/apps/${connect.connectionId}/permissions`, @@ -12336,6 +12337,18 @@ describeEmbeddedPostgres("tool access service", () => { connection: expect.objectContaining({ healthStatus: "failed" }), }), }); + await db.delete(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connect.connectionId)); + const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {}); + const app = createRouteApp(db, boardSessionActor(company.id, "owner", "board")); + const url = `/api/tool-connections/${connect.connectionId}/${path}`; + const response = await (path === "catalog" ? request(app).get(url) : request(app).post(url)); + expect(response.status).toBe(422); + expect(response.body).toMatchObject({ + code: "oauth_refresh_missing", + error: "OAuth credentials have expired and need to be reconnected.", + details: { setupUrl: `/apps/${connect.connectionId}/permissions`, reconnectUrl: `/apps/${connect.connectionId}/permissions` }, + }); + expect(capture).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled(); const auditRows = await db .select() diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 3004d380b17..c4a47bd6d3b 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -2805,6 +2805,8 @@ function healthFailureHttpStatus(failure: { }): number { if (failure.status === "missing_secret") return 422; if (failure.code === "oauth_challenge") return 422; + if (failure.code === "oauth_refresh_missing") return 422; + if (failure.code === "oauth_reauthorization_required") return 422; if (failure.code === "slack_mcp_access_disabled") return 422; if (failure.code === "user_authorization_required") return 422; if (failure.code === "composio_broker_retired") return 422;