diff --git a/src/lib/api/infrastructure.ts b/src/lib/api/infrastructure.ts index 23f90e1da..15694b0d1 100644 --- a/src/lib/api/infrastructure.ts +++ b/src/lib/api/infrastructure.ts @@ -102,6 +102,18 @@ function enrich403Detail(rawDetail: string | undefined): string { * @see https://github.com/getsentry/sentry/blob/934f1473f198a62f9268d7140b80cd9ca1e59bb9/src/sentry/api/authentication.py#L536-L539 */ export function enrich401Detail(rawDetail: string | undefined): string { + // Seat-limit lockout, not an auth failure. Sentry returns 401 with + // `code: member-disabled-over-limit` when the org is over its member limit + // and the caller's seat is disabled — re-authenticating cannot fix this. + if (rawDetail?.includes("member-disabled-over-limit")) { + return [ + "Your account is disabled in this organization because it is over its member limit.", + "This is a billing/seat-limit issue, not an auth problem — re-authenticating won't help.", + "Ask an org owner to upgrade the plan or free up a seat, then retry.", + "Or target a different org, e.g.: sentry init my-other-org/", + ].join("\n "); + } + const lines: string[] = []; if (rawDetail) { lines.push(rawDetail, ""); diff --git a/src/lib/init/preflight.ts b/src/lib/init/preflight.ts index 4aa993fec..9111725bb 100644 --- a/src/lib/init/preflight.ts +++ b/src/lib/init/preflight.ts @@ -310,6 +310,23 @@ async function resolveExistingProjectChoice(opts: { }; } +/** + * Normalize a team-resolution failure into a WizardError, preserving an + * ApiError's enriched detail (e.g. 401 `member-disabled-over-limit`) via + * format() instead of collapsing to its bare message + status line. + */ +function toPreflightWizardError(error: unknown): WizardError { + if (error instanceof WizardError) { + return error; + } + if (error instanceof ApiError) { + return new WizardError(error.format()); + } + return new WizardError( + error instanceof Error ? error.message : String(error) + ); +} + async function resolveTeam( org: string, initial: WizardOptions, @@ -334,9 +351,7 @@ async function resolveTeam( if (error instanceof ApiError && error.status === 403) { return; } - throw error instanceof WizardError - ? error - : new WizardError(error instanceof Error ? error.message : String(error)); + throw toPreflightWizardError(error); } } @@ -381,9 +396,7 @@ async function listTeamsForImplicitInit( if (error instanceof ApiError && error.status === 404) { return await buildOrgNotFoundError(org, "sentry init"); } - throw error instanceof WizardError - ? error - : new WizardError(error instanceof Error ? error.message : String(error)); + throw toPreflightWizardError(error); } } diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts index dcca1883d..59289a01a 100644 --- a/src/lib/resolve-team.ts +++ b/src/lib/resolve-team.ts @@ -119,6 +119,8 @@ export type ResolvedTeam = ResolvedConcreteTeam | DeferredResolvedTeam; * - 403 → member lacks team:read; re-thrown as `ApiError` so callers that * implement a member-accessible fallback can detect it and use * POST /organizations/{org}/projects/ instead. + * - 401 → re-thrown as `ApiError` so the enriched detail (expired session, + * member-disabled-over-limit, etc.) survives instead of being flattened. * - other → generic ResolutionError (5xx, network, etc.) */ async function handleListTeamsError( @@ -137,6 +139,9 @@ async function handleListTeamsError( if (error.status === 403) { throw error; } + if (error.status === 401) { + throw error; + } throw new ResolutionError( `Organization '${orgSlug}'`, `could not be accessed (${error.status})`, diff --git a/test/lib/api/infrastructure.test.ts b/test/lib/api/infrastructure.test.ts index 8eb76a9ed..910de5e1d 100644 --- a/test/lib/api/infrastructure.test.ts +++ b/test/lib/api/infrastructure.test.ts @@ -445,6 +445,39 @@ describe("throwApiError", () => { } }); + test("treats member-disabled-over-limit as a seat-limit issue, not auth", () => { + const mockResponse = new Response("", { + status: 401, + statusText: "Unauthorized", + }); + + let captured: ApiError | undefined; + try { + throwApiError( + { + detail: { + code: "member-disabled-over-limit", + message: "Organization over member limit", + extra: { next: "/organizations/chisme/disabled-member/" }, + }, + }, + mockResponse, + "Failed to list teams" + ); + } catch (error) { + captured = error as ApiError; + } + + expect(captured).toBeDefined(); + expect(captured?.status).toBe(401); + expect(captured?.detail).toContain("over its member limit"); + expect(captured?.detail).toContain("billing/seat-limit"); + // The fix must NOT give the misleading re-auth advice for this case. + expect(captured?.detail).not.toContain("sentry auth login"); + expect(captured?.detail).not.toContain("session has expired"); + expect(captured?.detail).not.toContain("SENTRY_AUTH_TOKEN"); + }); + describe("with OAuth token (no env var)", () => { let savedAuthToken: string | undefined; let savedToken: string | undefined; diff --git a/test/lib/init/preflight.test.ts b/test/lib/init/preflight.test.ts index 6aa3f4110..e48f0901e 100644 --- a/test/lib/init/preflight.test.ts +++ b/test/lib/init/preflight.test.ts @@ -46,7 +46,7 @@ vi.mock("../../../src/lib/dsn/index.js", async (importOriginal) => { // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as dsnIndex from "../../../src/lib/dsn/index.js"; -import { ApiError } from "../../../src/lib/errors.js"; +import { ApiError, WizardError } from "../../../src/lib/errors.js"; vi.mock("../../../src/lib/init/org-prefetch.js", async (importOriginal) => { const actual = @@ -583,6 +583,80 @@ describe("resolveInitContext", () => { expect(errorCall?.message).toContain("beta"); }); + test("surfaces the enriched detail when implicit listTeams returns 401", async () => { + // member-disabled-over-limit: a 401 from listTeams must reach the user with + // its actionable detail, not a bare "Failed to list teams" + status line. + listTeamsSpy.mockRejectedValueOnce( + new ApiError( + "Failed to list teams", + 401, + "Your account is disabled in this organization because it is over its member limit." + ) + ); + + const { ui, calls } = createMockUI(); + await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow(); + + const errorCall = calls.find( + (c): c is Extract => + c.kind === "log.error" + ); + expect(errorCall?.message).toContain("over its member limit"); + }); + + test("surfaces the enriched detail when explicit --team listTeams returns 401", async () => { + resolveOrCreateTeamSpy.mockRejectedValueOnce( + new ApiError( + "Failed to list teams", + 401, + "Your account is disabled in this organization because it is over its member limit." + ) + ); + + const { ui, calls } = createMockUI(); + await expect( + resolveInitContext(makeOptions({ team: "backend" }), ui) + ).rejects.toThrow(); + + const errorCall = calls.find( + (c): c is Extract => + c.kind === "log.error" + ); + expect(errorCall?.message).toContain("over its member limit"); + }); + + test("passes a pre-rendered WizardError through team resolution unchanged", async () => { + listTeamsSpy.mockRejectedValueOnce( + new WizardError("custom preflight failure") + ); + + const { ui, calls } = createMockUI(); + await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow( + "custom preflight failure" + ); + + const errorCall = calls.find( + (c): c is Extract => + c.kind === "log.error" + ); + expect(errorCall?.message).toBe("custom preflight failure"); + }); + + test("surfaces a non-API error message from implicit team resolution", async () => { + listTeamsSpy.mockRejectedValueOnce(new Error("network down")); + + const { ui, calls } = createMockUI(); + await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow( + "network down" + ); + + const errorCall = calls.find( + (c): c is Extract => + c.kind === "log.error" + ); + expect(errorCall?.message).toContain("network down"); + }); + test("fails early when listTeams is forbidden and member project creation is disabled", async () => { listTeamsSpy.mockRejectedValueOnce( new ApiError("Forbidden", 403, "No team:read access") diff --git a/test/lib/resolve-team.test.ts b/test/lib/resolve-team.test.ts new file mode 100644 index 000000000..5c98c9415 --- /dev/null +++ b/test/lib/resolve-team.test.ts @@ -0,0 +1,43 @@ +/** + * Tests for resolveOrCreateTeam error handling. Mocks the teams API so + * listTeams failures can be exercised without real HTTP calls. + */ + +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../src/lib/api/teams.js"); +vi.mock("../../src/lib/api/organizations.js"); + +// biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking +import * as teamsApi from "../../src/lib/api/teams.js"; +import { ApiError, ResolutionError } from "../../src/lib/errors.js"; +import { resolveOrCreateTeam } from "../../src/lib/resolve-team.js"; + +describe("resolveOrCreateTeam", () => { + const listTeamsSpy = vi.mocked(teamsApi.listTeams); + + afterEach(() => { + listTeamsSpy.mockReset(); + }); + + test("re-throws the original ApiError when listTeams returns 401", async () => { + // member-disabled-over-limit and other 401s must keep their enriched detail + // instead of being flattened into a generic ResolutionError. + const apiError = new ApiError( + "Failed to list teams", + 401, + "Your account is disabled in this organization because it is over its member limit." + ); + listTeamsSpy.mockRejectedValueOnce(apiError); + + const error = await resolveOrCreateTeam("chisme", { + usageHint: "sentry init", + }).catch((e) => e); + + expect(error).toBe(apiError); + expect(error).toBeInstanceOf(ApiError); + expect(error).not.toBeInstanceOf(ResolutionError); + expect(error.status).toBe(401); + expect(error.detail).toContain("over its member limit"); + }); +});