From 48694c3ae882fa3cd3377b2d464ad95758a1569c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:34:51 +0000 Subject: [PATCH] feat(cli): List organizations across all stored accounts With several stored accounts, `sentry org list` now lists the organizations of every logged-in account together. Each account is queried concurrently with its own credentials on its own server (`withStoredAccount`, an isolated env copy with its own account scope), so tokens never cross servers and the stored active account is unchanged. Human output adds an ACCOUNT column and JSON entries carry an `account` object. - An org 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. - An account whose request fails is skipped with a warning; the command only fails when every account does. - An explicit server (SENTRY_HOST/SENTRY_URL, .sentryclirc) limits the list to accounts on it; `--account ` limits it to one account. - Forced env tokens and single-account setups keep the previous output. Listing also caches each account's org list, which feeds automatic account selection for org-scoped commands. Co-Authored-By: Burak Yigit Kaya --- apps/cli-docs/src/fragments/commands/auth.md | 7 +- apps/cli-docs/src/fragments/commands/org.md | 20 ++ .../skills/sentry-cli/references/org.md | 4 + packages/cli/src/commands/org/list.ts | 258 ++++++++++++-- packages/cli/src/lib/db/auth.ts | 60 +++- packages/cli/test/commands/org/list.test.ts | 335 ++++++++++++++++++ 6 files changed, 655 insertions(+), 29 deletions(-) create mode 100644 packages/cli/test/commands/org/list.test.ts 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([]); + }); +});