diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md index 6a4455666..550910e49 100644 --- a/apps/cli-docs/src/fragments/commands/auth.md +++ b/apps/cli-docs/src/fragments/commands/auth.md @@ -96,9 +96,10 @@ Most of the time you don't need to switch: commands pick the stored account that fits what they target, without changing the active account: - **Organization** — when the target organization isn't in the active - account's organization list (cached at login and by `sentry org list`) but - another stored account has used it, that account is used. This includes - `oNNNN` IDs from DSNs. + account's organization list (cached at login and by `sentry org list`, + which lists every stored account's organizations) but another stored + account has used it, that account is used. This includes `oNNNN` IDs from + DSNs. - **Server** — `SENTRY_HOST`/`SENTRY_URL`, `.sentryclirc`, or a pasted Sentry URL for another account's server (for example a self-hosted issue link) uses the account stored for that server. diff --git a/apps/cli-docs/src/fragments/commands/org.md b/apps/cli-docs/src/fragments/commands/org.md index d102dfde4..ad660bac7 100644 --- a/apps/cli-docs/src/fragments/commands/org.md +++ b/apps/cli-docs/src/fragments/commands/org.md @@ -13,6 +13,26 @@ my-org My Organization owner another-org Another Org member ``` +With several stored accounts (see `sentry auth list`), `sentry org list` lists +the organizations of every logged-in account together, each fetched with that +account's own credentials on its own server, plus an `ACCOUNT` column. An +organization reachable from several accounts on the same server is listed once, +under the active account if it is one of them, else the most recently used. +Accounts whose login fails are skipped with a warning. `SENTRY_HOST`/`SENTRY_URL` limits the list to accounts on that server, +and `--account` to a single account. The stored active account never changes. + +``` +$ sentry org list +SLUG NAME ACCOUNT +my-org My Organization jane@example.com on sentry.io +acme-internal Acme Internal jane@acme.com on sentry.acme.com +``` + +```bash +# Only one account's organizations +sentry org list --account jane@acme.com +``` + ```bash # View organization details sentry org view my-org diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md index 1eaf2afbe..a99dde68f 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md @@ -18,6 +18,7 @@ List organizations **Flags:** - `-n, --limit - Maximum number of organizations to list - (default: "25")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `--account - Only list organizations of this stored account (email, username, or account id)` ### `sentry org view []` @@ -33,6 +34,9 @@ View details of an organization # List organizations sentry org list +# Only one account's organizations +sentry org list --account jane@acme.com + # View organization details sentry org view my-org diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index 5a6a43eb5..cf5ec03e8 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -1,14 +1,27 @@ /** * sentry org list * - * List organizations the user has access to. + * List organizations the user has access to — across every stored account + * when several are logged in. */ import type { SentryContext } from "../../context.js"; +import { resolveAccountSelector } from "../../lib/account-selection.js"; import { listOrganizationsUncached } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; +import { + type AccountSummary, + isSameServer, + toAccountSummary, +} from "../../lib/db/accounts.js"; +import { + listUsableAccountsInScope, + withStoredAccount, +} from "../../lib/db/auth.js"; import { getAllOrgRegions } from "../../lib/db/regions.js"; +import { ApiError, AuthError } from "../../lib/errors.js"; +import { formatAccountLabel } from "../../lib/formatters/accounts.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; @@ -26,17 +39,40 @@ type ListFlags = { readonly limit: number; readonly json: boolean; readonly fresh: boolean; + readonly account?: string; readonly fields?: string[]; }; +/** The stored account an organization was listed under (no secrets). */ +type OrgListAccount = { + id: string; + host?: string; + email?: string; + username?: string; + /** Human label, e.g. `jane@acme.com on sentry.io`. */ + label: string; +}; + /** - * An organization enriched with an optional region display name. + * An organization enriched with an optional region display name and the + * stored account it was listed under. * * The `region` field is only present when the user's orgs span multiple - * regions (e.g. both US and EU). It is included in JSON output as well, - * providing region context to machine consumers. + * regions (e.g. both US and EU). `account` is only present when orgs from + * several stored accounts are listed. Both are included in JSON output as + * well, providing context to machine consumers. */ -type OrgListEntry = SentryOrganization & { region?: string }; +type OrgListEntry = SentryOrganization & { + region?: string; + account?: OrgListAccount; +}; + +/** One account's fetched orgs plus its slug → region URL cache. */ +type AccountOrgs = { + account?: AccountSummary; + orgs: SentryOrganization[]; + regions: Map; +}; /** * Extract a human-readable region name from a region URL. @@ -89,12 +125,19 @@ function formatOrgListHuman(entries: OrgListEntry[]): string { } const showRegion = entries.some((e) => e.region !== undefined); + const showAccount = entries.some((e) => e.account !== undefined); - type OrgRow = { slug: string; name: string; region?: string }; + type OrgRow = { + slug: string; + name: string; + region?: string; + account?: string; + }; const rows: OrgRow[] = entries.map((org) => ({ slug: org.slug, name: org.name, region: showRegion ? (org.region ?? "") : undefined, + account: org.account?.label, })); const columns: Column[] = [ @@ -103,6 +146,14 @@ function formatOrgListHuman(entries: OrgListEntry[]): string { ? [{ header: "REGION", value: (r: OrgRow) => r.region ?? "" }] : []), { header: "NAME", value: (r) => escapeMarkdownCell(r.name) }, + ...(showAccount + ? [ + { + header: "ACCOUNT", + value: (r: OrgRow) => escapeMarkdownCell(r.account ?? ""), + }, + ] + : []), ]; const parts: string[] = []; @@ -112,14 +163,151 @@ function formatOrgListHuman(entries: OrgListEntry[]): string { return parts.join("").trimEnd(); } +function toOrgListAccount(account: AccountSummary): OrgListAccount { + return { + id: account.id, + ...(account.host ? { host: account.host } : {}), + ...(account.email ? { email: account.email } : {}), + ...(account.username ? { username: account.username } : {}), + label: formatAccountLabel(account), + }; +} + +/** Fetch orgs (and their cached regions) with the credentials in effect. */ +async function fetchOrgs(account?: AccountSummary): Promise { + const orgs = await listOrganizationsUncached(); + return { account, orgs, regions: getAllOrgRegions() }; +} + +/** Fetch `account`'s orgs with its own credentials, on its own server. */ +function fetchAccountOrgs(account: AccountSummary): Promise { + const host = account.host; + if (!host) { + return Promise.reject( + new AuthError("not_authenticated", "Account has no server recorded.", { + skipAutoAuth: true, + }), + ); + } + return withStoredAccount({ id: account.id, host }, () => fetchOrgs(account)); +} + +/** + * Fetch every account's orgs concurrently. An account that fails (expired + * login, unreachable server) is skipped with a warning so the others are + * still listed; when every account fails, the first error is thrown. + */ +async function fetchOrgsAcrossAccounts( + accounts: AccountSummary[], +): Promise { + const settled = await Promise.allSettled(accounts.map(fetchAccountOrgs)); + const fetched: AccountOrgs[] = []; + let firstError: unknown; + settled.forEach((result, i) => { + if (result.status === "fulfilled") { + fetched.push(result.value); + return; + } + const error = result.reason; + if (!(error instanceof AuthError || error instanceof ApiError)) { + throw error; + } + firstError ??= error; + const account = accounts[i]; + log.warn( + `Skipped ${account ? formatAccountLabel(account) : "an account"}: ${error.message}`, + ); + }); + if (fetched.length === 0 && firstError !== undefined) { + throw firstError; + } + return fetched; +} + +/** + * Flatten per-account results into list entries. An org reachable from + * several accounts on the same server is listed once, under the first of + * them (the active account, else the most recently used). + */ +function mergeAccountOrgs(results: AccountOrgs[]): { + orgs: SentryOrganization[]; + accountOf: Map; + regionOf: Map; +} { + const orgs: SentryOrganization[] = []; + const accountOf = new Map(); + const regionOf = new Map(); + const seen: { host: string; slug: string }[] = []; + for (const { account, orgs: accountOrgs, regions } of results) { + for (const org of accountOrgs) { + const host = account?.host; + if ( + host && + seen.some((s) => s.slug === org.slug && isSameServer(s.host, host)) + ) { + continue; + } + if (host) { + seen.push({ host, slug: org.slug }); + } + orgs.push(org); + accountOf.set(org, account); + regionOf.set(org, regions.get(org.slug)); + } + } + return { orgs, accountOf, regionOf }; +} + +/** + * Which stored accounts to list: the one named by `--account`, else every + * usable account in scope. `undefined` means "the credentials in effect" + * (one or no stored account, or a forced env token), which keeps the + * single-account output unchanged. + */ +function resolveAccounts(selector?: string): AccountSummary[] | undefined { + if (selector !== undefined) { + const account = resolveAccountSelector( + selector, + "sentry org list --account", + { + fuzzy: true, + }, + ); + if (!account.usable) { + const who = account.email ?? account.username; + const relogin = who ? `sentry auth login ${who}` : "sentry auth login"; + throw new AuthError( + "expired", + `${formatAccountLabel(account)} is logged out. Run '${relogin}'.`, + { skipAutoAuth: true }, + ); + } + return [account]; + } + // The active account first, then most recently used: its orgs lead the + // list, and an org it shares with another account is listed under it. + const accounts = listUsableAccountsInScope() + .map(toAccountSummary) + .sort((a, b) => Number(b.active) - Number(a.active)); + return accounts.length > 1 ? accounts : undefined; +} + export const listCommand = buildCommand({ docs: { brief: "List organizations", fullDescription: "List organizations that you have access to.\n\n" + + "With several stored accounts (see 'sentry auth list'), organizations\n" + + "from every logged-in account are listed together, with an ACCOUNT\n" + + "column showing which login reaches each one. Commands targeting an\n" + + "organization use that account automatically. An organization reachable\n" + + "from several accounts on the same server is listed once. Accounts whose\n" + + "login fails are skipped with a warning. SENTRY_HOST/SENTRY_URL limits the\n" + + "list to accounts on that server; --account limits it to one account.\n\n" + "Examples:\n" + " sentry org list\n" + " sentry org list --limit 10\n" + + " sentry org list --account jane@example.com\n" + " sentry org list --json\n\n" + "Alias: `sentry orgs` → `sentry org list`", }, @@ -128,6 +316,13 @@ export const listCommand = buildCommand({ flags: { limit: buildListLimitFlag("organizations"), fresh: FRESH_FLAG, + account: { + kind: "parsed", + parse: String, + brief: + "Only list organizations of this stored account (email, username, or account id)", + optional: true, + }, }, // Only -n for --limit; no -c since org list has no --cursor flag aliases: { ...FRESH_ALIASES, n: "limit" }, @@ -135,26 +330,49 @@ export const listCommand = buildCommand({ async *func(this: SentryContext, flags: ListFlags) { applyFreshFlag(flags); - const orgs = await withProgress( + const accounts = resolveAccounts(flags.account); + const crossAccount = accounts !== undefined && accounts.length > 1; + const results = await withProgress( { - message: `Fetching organizations (up to ${flags.limit})...`, + message: crossAccount + ? `Fetching organizations from ${accounts.length} accounts (up to ${flags.limit})...` + : `Fetching organizations (up to ${flags.limit})...`, json: flags.json, }, - () => listOrganizationsUncached(), + async () => { + if (!accounts) { + return [await fetchOrgs()]; + } + return crossAccount + ? await fetchOrgsAcrossAccounts(accounts) + : await Promise.all(accounts.map(fetchAccountOrgs)); + }, ); + const { orgs, accountOf, regionOf } = mergeAccountOrgs(results); const limitedOrgs = orgs.slice(0, flags.limit); - // Check if user has orgs in multiple regions - const orgRegions = getAllOrgRegions(); - const uniqueRegions = new Set(orgRegions.values()); - const showRegion = uniqueRegions.size > 1; - - const entries: OrgListEntry[] = limitedOrgs.map((org) => ({ - ...org, - region: showRegion - ? getRegionDisplayName(orgRegions.get(org.slug) ?? "") - : undefined, - })); + // Show regions when one login's orgs span several (e.g. US and EU). + // Accounts on different servers alone don't count: ACCOUNT shows those. + const showRegion = results.some( + ({ orgs: accountOrgs, regions }) => + new Set( + accountOrgs.flatMap((org) => { + const region = regions.get(org.slug); + return region ? [region] : []; + }), + ).size > 1, + ); + + const entries: OrgListEntry[] = limitedOrgs.map((org) => { + const account = crossAccount ? accountOf.get(org) : undefined; + return { + ...org, + region: showRegion + ? getRegionDisplayName(regionOf.get(org) ?? "") + : undefined, + ...(account ? { account: toOrgListAccount(account) } : {}), + }; + }); const hints: string[] = []; if (orgs.length > flags.limit) { diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 7a52a7079..877cd8912 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -23,7 +23,7 @@ import { isPersistedDefaultUrlInEffect, markPersistedDefaultUrl, } from "../constants.js"; -import { getEnv } from "../env.js"; +import { getEnv, withEnv } from "../env.js"; import { getBootConfiguredSentryUrl, getEnvTokenHost, @@ -865,6 +865,54 @@ export function selectAccountForOrg(orgSlug: string, orgId?: string): void { } } +/** A stored account a command can run as: its id and server. */ +export type StoredAccountRef = Readonly<{ id: string; host: string }>; + +/** + * Usable stored accounts a cross-account command (`sentry org list`) + * covers, most recently used first. An explicit server (`SENTRY_HOST`/ + * `SENTRY_URL`, `.sentryclirc`) limits them to accounts on that server, the + * same rule as {@link selectAccountForOrg}. Empty when a forced env token + * (`SENTRY_FORCE_ENV_TOKEN`) overrides stored accounts. + */ +export function listUsableAccountsInScope(): AccountRow[] { + if (getEnv().SENTRY_FORCE_ENV_TOKEN?.trim() && getRawEnvToken()) { + return []; + } + const configured = getConfiguredUrlOrUndefined(); + const explicitHost = + configured && !isPersistedDefaultUrlInEffect() ? configured : undefined; + return withDbSpan("listUsableAccountsInScope", () => + listUsableAccountRowsByRecency().filter((row) => { + const host = row.host ?? migrateNullHost(row); + return !explicitHost || isSameServer(host, explicitHost); + }), + ); +} + +/** + * Run `fn` as the stored `account`, on its own server, without touching the + * invocation's account selection or the stored active account. + * + * `fn` runs under a copy of the invocation env (see `withEnv`), so its + * account scope, memoized credentials, and configured server are its own: + * several accounts can be queried concurrently, and credential reads, token + * refreshes, and invalidation inside `fn` all target `account`. The URL is + * pinned to the account's server so its token is never sent to another. + */ +export function withStoredAccount( + account: StoredAccountRef, + fn: () => Promise, +): Promise { + const env: NodeJS.ProcessEnv = { ...getEnv() }; + delete env.SENTRY_HOST; + env.SENTRY_URL = account.host; + return withEnv(env, () => { + getAccountScope().accountId = account.id; + return fn(); + }); +} + /** * The stored account whose credentials hash to `identity` (following * in-process refresh-token rotations), so cached org data can record which @@ -894,11 +942,6 @@ export function getAccountIdForIdentity(identity: string): string | undefined { } } -/** - * Whether a usable stored account is on `url`'s server. Lets a pasted - * Sentry URL for another login's server select that login instead of - * failing the host check against the active account. - */ /** * Host of the stored account in effect, ignoring env-var tokens (reading an * env token's host would snapshot it). `undefined` when no stored account @@ -913,6 +956,11 @@ export function getEffectiveStoredAccountHost(): string | undefined { } } +/** + * Whether a usable stored account is on `url`'s server. Lets a pasted + * Sentry URL for another login's server select that login instead of + * failing the host check against the active account. + */ export function hasStoredAccountForHost(url: string): boolean { try { return listUsableAccountRowsByRecency().some( diff --git a/packages/cli/test/commands/org/list.test.ts b/packages/cli/test/commands/org/list.test.ts new file mode 100644 index 000000000..fd281ca3e --- /dev/null +++ b/packages/cli/test/commands/org/list.test.ts @@ -0,0 +1,335 @@ +/** + * `sentry org list` across stored accounts, against a real test database + * and a fetch mock that answers per server and per token. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { listCommand } from "../../../src/commands/org/list.js"; +import { + getActiveAccountId, + getAuthToken, + selectAccountForOrg, + setActiveAccount, + setAuthToken, +} from "../../../src/lib/db/auth.js"; +import { resetAccountScopes } from "../../../src/lib/db/account-scope.js"; +import { getDatabase } from "../../../src/lib/db/index.js"; +import { setUserInfo } from "../../../src/lib/db/user.js"; +import { AuthError, ResolutionError } from "../../../src/lib/errors.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +type ListFunc = ( + this: unknown, + flags: { + limit: number; + json: boolean; + fresh: boolean; + account?: string; + }, +) => Promise; + +useTestConfigDir("org-list-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +]); + +const SAAS = "https://sentry.io"; +const ACME = "https://sentry.acme.com"; + +type Org = { id: string; slug: string; name: string; regionUrl: string }; + +/** Orgs each token can see; a token missing here gets a 401. */ +let orgsByToken: Record; +/** ` ` for every org-list request, to check token routing. */ +let requests: string[]; +let originalFetch: typeof globalThis.fetch; + +function org(slug: string, regionUrl = SAAS, id = slug): Org { + return { id, slug, name: `${slug} name`, regionUrl }; +} + +beforeEach(() => { + orgsByToken = {}; + requests = []; + originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = new URL(req.url); + const token = req.headers.get("Authorization")?.replace(/^Bearer /, ""); + if (!url.pathname.startsWith("/api/0/organizations/")) { + return Response.json({ detail: "Not found" }, { status: 404 }); + } + requests.push(`${url.origin} ${token}`); + const orgs = token ? orgsByToken[token] : undefined; + if (!orgs) { + return Response.json({ detail: "Invalid token" }, { status: 401 }); + } + return Response.json( + orgs.map((o) => ({ + id: o.id, + slug: o.slug, + name: o.name, + links: { organizationUrl: o.regionUrl, regionUrl: o.regionUrl }, + })), + ); + }) as typeof globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +function addAccount(token: string, host: string, email: string): string { + const id = setAuthToken(token, undefined, undefined, { + host, + newAccount: true, + }); + setUserInfo({ userId: email, email }); + return id; +} + +function createContext() { + const chunks: string[] = []; + return { + context: { + stdout: { write: vi.fn((s: string) => chunks.push(s)) }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + getOutput: () => chunks.join(""), + }; +} + +async function runList( + flags: { account?: string; limit?: number } = {}, +): Promise[]> { + const func = (await listCommand.loader()) as unknown as ListFunc; + const { context, getOutput } = createContext(); + await func.call(context, { + limit: flags.limit ?? 100, + json: true, + fresh: true, + ...(flags.account ? { account: flags.account } : {}), + }); + return JSON.parse(getOutput()) as Record[]; +} + +async function runListHuman(): Promise { + const func = (await listCommand.loader()) as unknown as ListFunc; + const { context, getOutput } = createContext(); + await func.call(context, { limit: 100, json: false, fresh: true }); + return getOutput(); +} + +function slugsWithAccount(entries: Record[]): string[][] { + return entries.map((e) => [ + e.slug as string, + ((e.account as { email?: string } | undefined)?.email ?? "") as string, + ]); +} + +describe("org list with one stored account", () => { + test("lists that account's orgs without account info", async () => { + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha"), org("beta")]; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["alpha", "beta"]); + expect(entries.every((e) => e.account === undefined)).toBe(true); + expect(requests).toEqual([`${SAAS} saas-token`]); + }); +}); + +describe("org list across stored accounts", () => { + test("lists every account's orgs, each fetched with its own token on its own server", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const entries = await runList(); + + expect(slugsWithAccount(entries)).toEqual([ + ["alpha", "jane@example.com"], + ["acme-internal", "jane@acme.com"], + ]); + expect(entries[1]?.account).toMatchObject({ + host: ACME, + label: "jane@acme.com on sentry.acme.com", + }); + expect(requests.sort()).toEqual( + [`${SAAS} saas-token`, `${ACME} acme-token`].sort(), + ); + }); + + test("caches each account's orgs, so later commands route to the right account", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + await runList(); + resetAccountScopes(); + selectAccountForOrg("acme-internal"); + + expect(getAuthToken()).toBe("acme-token"); + }); + + test("never changes the stored active account", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + const saas = addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + await runList(); + + expect(getActiveAccountId()).toBe(saas); + }); + + test("an org reachable from two accounts on the same server is listed once, under the active account", async () => { + const work = addAccount("work-token", SAAS, "jane@work.com"); + addAccount("personal-token", SAAS, "jane@example.com"); + setActiveAccount(work); + orgsByToken["work-token"] = [org("shared"), org("work-only")]; + orgsByToken["personal-token"] = [org("shared"), org("personal-only")]; + + const entries = await runList(); + + expect(slugsWithAccount(entries)).toEqual([ + ["shared", "jane@work.com"], + ["work-only", "jane@work.com"], + ["personal-only", "jane@example.com"], + ]); + }); + + test("the same slug on different servers is listed for each", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("acme")]; + orgsByToken["acme-token"] = [org("acme", ACME)]; + + const entries = await runList(); + + expect(slugsWithAccount(entries)).toEqual([ + ["acme", "jane@example.com"], + ["acme", "jane@acme.com"], + ]); + }); + + test("an account whose login is rejected is skipped; the others are still listed", async () => { + addAccount("revoked-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + + const entries = await runList(); + + expect(slugsWithAccount(entries)).toEqual([["alpha", "jane@example.com"]]); + // Only the rejected account's credentials are touched. + expect(getAuthToken()).toBe("saas-token"); + }); + + test("fails when every account fails", async () => { + addAccount("revoked-a", ACME, "jane@acme.com"); + addAccount("revoked-b", SAAS, "jane@example.com"); + + await expect(runList()).rejects.toThrow(); + }); + + test("an explicit server limits the list to accounts on it", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + process.env.SENTRY_URL = ACME; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["acme-internal"]); + expect(requests).toEqual([`${ACME} acme-token`]); + }); + + test("skips accounts that are logged out", async () => { + const acme = addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + getDatabase() + .query("UPDATE accounts SET token = NULL WHERE account_id = ?") + .run(acme); + orgsByToken["saas-token"] = [org("alpha")]; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["alpha"]); + expect(requests).toEqual([`${SAAS} saas-token`]); + }); + + test("--limit applies to the merged list", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha"), org("beta")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const entries = await runList({ limit: 2 }); + + expect(entries.map((e) => e.slug)).toEqual(["alpha", "beta"]); + }); + + test("human output adds an ACCOUNT column", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const out = await runListHuman(); + + expect(out).toContain("ACCOUNT"); + expect(out).toContain("sentry.acme.com"); + expect(out).not.toContain("REGION"); + }); +}); + +describe("org list --account", () => { + test("lists only that account's orgs, with its own token", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const entries = await runList({ account: "jane@acme.com" }); + + expect(entries.map((e) => e.slug)).toEqual(["acme-internal"]); + expect(entries[0]?.account).toBeUndefined(); + expect(requests).toEqual([`${ACME} acme-token`]); + }); + + test("an unknown account is an error", async () => { + addAccount("saas-token", SAAS, "jane@example.com"); + + await expect(runList({ account: "nobody@nowhere.dev" })).rejects.toThrow( + ResolutionError, + ); + }); + + test("a logged-out account is an auth error naming how to log back in", async () => { + const acme = addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + getDatabase() + .query("UPDATE accounts SET token = NULL WHERE account_id = ?") + .run(acme); + + const error = await runList({ account: "jane@acme.com" }).catch( + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(AuthError); + expect((error as AuthError).message).toContain( + "sentry auth login jane@acme.com", + ); + expect(requests).toEqual([]); + }); +});