Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Comment thread
betegon marked this conversation as resolved.
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, "");
Expand Down
25 changes: 19 additions & 6 deletions src/lib/init/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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);
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/lib/resolve-team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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})`,
Expand Down
33 changes: 33 additions & 0 deletions test/lib/api/infrastructure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
76 changes: 75 additions & 1 deletion test/lib/init/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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<MockCall, { kind: "log.error" }> =>
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<MockCall, { kind: "log.error" }> =>
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<MockCall, { kind: "log.error" }> =>
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<MockCall, { kind: "log.error" }> =>
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")
Expand Down
43 changes: 43 additions & 0 deletions test/lib/resolve-team.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading