diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 5f03bfb33..8c0907f1e 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -533,9 +533,13 @@ async function handleProjectNotFound( projectSlug: string, orgs: { slug: string }[], flags: ListFlags, - options?: { originalSlug?: string; isRecoveryAttempt?: boolean } + options?: { + originalSlug?: string; + isRecoveryAttempt?: boolean; + scopedOrg?: string; + } ): Promise> { - const { originalSlug, isRecoveryAttempt = false } = options ?? {}; + const { originalSlug, isRecoveryAttempt = false, scopedOrg } = options ?? {}; const displaySlug = originalSlug ?? projectSlug; // Skip triage on recovery attempts to prevent infinite recursion. @@ -563,36 +567,49 @@ async function handleProjectNotFound( if (outcome.kind === "fuzzy-match") { // Pass isRecoveryAttempt=true to prevent infinite recursion if the - // fuzzy-recovered slug also fails to resolve. - return handleProjectSearch(outcome.project, flags, undefined, true); + // fuzzy-recovered slug also fails to resolve. Preserve scopedOrg so + // the recovery lookup stays scoped to the user's specified org. + return handleProjectSearch(outcome.project, flags, { + isRecoveryAttempt: true, + scopedOrg, + }); } // JSON mode returns empty array; human mode throws a helpful error if (flags.json) { return { items: [] }; } + const fallback = scopedOrg + ? [ + `No project with this name found in organization '${scopedOrg}'`, + `Check the organization slug or try: sentry project list ${scopedOrg}/`, + ] + : ["No project with this slug found in any accessible organization"]; throw new ResolutionError( `Project '${displaySlug}'`, "not found", `sentry project list /${projectSlug}`, - outcome.suggestions.length > 0 - ? outcome.suggestions - : ["No project with this slug found in any accessible organization"] + outcome.suggestions.length > 0 ? outcome.suggestions : fallback ); } export async function handleProjectSearch( projectSlug: string, flags: ListFlags, - /** Original user input before normalization — for clearer messages. */ - originalSlug?: string, - /** @internal — prevents infinite recursion from fuzzy recovery. */ - _isRecoveryAttempt = false + options?: { + /** Original user input before normalization — for clearer messages. */ + originalSlug?: string; + /** @internal — prevents infinite recursion from fuzzy recovery. */ + isRecoveryAttempt?: boolean; + /** Organization slug to scope the search to (e.g. from "org/My Project"). */ + scopedOrg?: string; + } ): Promise> { + const { originalSlug, isRecoveryAttempt = false, scopedOrg } = options ?? {}; // When the input is a display name (originalSlug set, contains spaces), // skip the slug-based API lookup and go straight to name-based matching. const isDisplayName = originalSlug !== undefined; - const { projects, orgs } = isDisplayName + const { projects, orgs: foundOrgs } = isDisplayName ? { projects: [], orgs: await listOrganizations() } : await withProgress( { @@ -601,10 +618,26 @@ export async function handleProjectSearch( }, () => findProjectsBySlug(projectSlug) ); - const filtered = filterByPlatform(projects, flags.platform); + + // When the caller provided an org (e.g. "org/My Project"), scope the + // search to that org instead of all accessible orgs. This applies to both + // display-name searches and slug-based recovery lookups. The slug-based + // lookup (findProjectsBySlug) fans out across every accessible org, so the + // recovered projects must be filtered too — otherwise a recovered slug that + // also exists in a different org could leak into a scoped result. + const orgs = + scopedOrg !== undefined + ? foundOrgs.filter((o) => o.slug === scopedOrg) + : foundOrgs; + const scopedProjects = + scopedOrg !== undefined + ? projects.filter((p) => p.orgSlug === scopedOrg) + : projects; + + const filtered = filterByPlatform(scopedProjects, flags.platform); if (filtered.length === 0) { - if (projects.length > 0 && flags.platform) { + if (scopedProjects.length > 0 && flags.platform) { return { items: [], hint: `No project '${projectSlug}' found matching platform '${flags.platform}'.`, @@ -613,7 +646,8 @@ export async function handleProjectSearch( return handleProjectNotFound(projectSlug, orgs, flags, { originalSlug, - isRecoveryAttempt: _isRecoveryAttempt, + isRecoveryAttempt, + scopedOrg, }); } @@ -731,11 +765,10 @@ export const listCommand = buildListCommand("project", { }); }, "project-search": (ctx) => - handleProjectSearch( - ctx.parsed.projectSlug, - flags, - ctx.parsed.originalSlug - ), + handleProjectSearch(ctx.parsed.projectSlug, flags, { + originalSlug: ctx.parsed.originalSlug, + scopedOrg: ctx.parsed.org, + }), }, }); diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 588de7738..a0e6c9273 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -369,6 +369,12 @@ export type ParsedOrgProject = projectSlug: string; /** True if project slug was normalized */ normalized?: boolean; + /** + * Organization slug to scope the search to, when the caller provided + * one (e.g. "org/My Project"). When unset the search spans all + * accessible organizations. + */ + org?: string; /** * Pre-normalization input when {@link normalized} is `true`. * Used by the resolution layer to produce user-friendly messages @@ -536,6 +542,18 @@ function parseSlashOrgProject(input: string): ParsedOrgProject { // "sentry/cli" → explicit org and project rejectAtSelector(rawProject, "project slug"); + if (looksLikeDisplayName(rawProject)) { + // Spaces → display name, not a slug. Skip slug validation and let the + // resolution layer do a fuzzy name-based search (mirrors the bare-slug + // and leading-slash paths). Prevents a hard ValidationError when callers + // pass a project display name in "org/project" form (CLI-1RA). + return { + type: "project-search", + projectSlug: rawProject, + originalSlug: rawProject, + org: no.slug, + }; + } const np = normalizeSlug(rawProject); validateResourceId(np.slug, "project slug"); const normalized = no.normalized || np.normalized; diff --git a/src/lib/org-list.ts b/src/lib/org-list.ts index 088bc80c5..74eab7d6e 100644 --- a/src/lib/org-list.ts +++ b/src/lib/org-list.ts @@ -773,17 +773,19 @@ export async function handleProjectSearch( orgAllFallback?: (orgSlug: string) => Promise>; /** Original user input before normalization — for clearer messages. */ originalSlug?: string; + /** Organization slug to scope the search to (e.g. from "org/My Project"). */ + org?: string; }, /** Guard against infinite recursion from fuzzy recovery. */ _isRecoveryAttempt = false ): Promise> { - const { flags, orgAllFallback, originalSlug } = options; + const { flags, orgAllFallback, originalSlug, org: scopedOrg } = options; /** Display label: the user's raw input when available, otherwise the slug. */ const displaySlug = originalSlug ?? projectSlug; // When the input is a display name (originalSlug set, contains spaces), // skip the slug-based API lookup and go straight to name-based matching. const isDisplayName = originalSlug !== undefined; - const { projects: matches, orgs } = isDisplayName + const { projects: rawMatches, orgs: foundOrgs } = isDisplayName ? { projects: [], orgs: await listOrganizations() } : await withProgress( { @@ -793,6 +795,21 @@ export async function handleProjectSearch( () => findProjectsBySlug(projectSlug) ); + // When the caller provided an org (e.g. "org/My Project"), scope the + // search to that org instead of all accessible orgs. This applies to both + // display-name searches and slug-based recovery lookups. The slug-based + // lookup (findProjectsBySlug) fans out across every accessible org, so the + // matched projects must be filtered too — otherwise a recovered slug that + // also exists in a different org could leak into a scoped result. + const orgs = + scopedOrg !== undefined + ? foundOrgs.filter((o) => o.slug === scopedOrg) + : foundOrgs; + const matches = + scopedOrg !== undefined + ? rawMatches.filter((m) => m.orgSlug === scopedOrg) + : rawMatches; + if (matches.length === 0) { // Skip triage on recovery attempts to prevent infinite recursion. const outcome: ProjectNotFoundOutcome = _isRecoveryAttempt @@ -833,13 +850,17 @@ export async function handleProjectSearch( return { items: [] }; } + const fallback = scopedOrg + ? [ + `No project with this name found in organization '${scopedOrg}'`, + `Check the organization slug or try: sentry project list ${scopedOrg}/`, + ] + : ["No project with this slug found in any accessible organization"]; throw new ResolutionError( `Project '${displaySlug}'`, "not found", `${config.commandPrefix} /${projectSlug}`, - outcome.suggestions.length > 0 - ? outcome.suggestions - : ["No project with this slug found in any accessible organization"] + outcome.suggestions.length > 0 ? outcome.suggestions : fallback ); } @@ -953,6 +974,7 @@ function buildDefaultHandlers( flags: ctx.flags, orgAllFallback: (orgSlug) => runOrgAll(config, orgSlug, ctx.flags), originalSlug: ctx.parsed.originalSlug, + org: ctx.parsed.org, }), "org-all": (ctx) => { @@ -1052,6 +1074,28 @@ async function resolveOrgSlugMatch( return { type: "org-all", org: matchingOrg.slug }; } +/** + * Resolve DSN-style org identifiers and set org/project context for modes + * that carry an org field. Returns the (possibly updated) parsed object. + */ +async function resolveOrgInParsed( + parsed: ParsedOrgProject +): Promise { + if (!("org" in parsed && parsed.org)) { + return parsed; + } + const effectiveOrg = await resolveEffectiveOrg(parsed.org); + const resolved = + effectiveOrg !== parsed.org ? { ...parsed, org: effectiveOrg } : parsed; + if (resolved.type === "explicit" || resolved.type === "org-all") { + setOrgProjectContext( + [effectiveOrg], + resolved.type === "explicit" ? [resolved.project] : [] + ); + } + return resolved; +} + /** * Validate the cursor flag and dispatch to the correct mode handler. * @@ -1101,19 +1145,7 @@ export async function dispatchOrgScopedList( ); } - if ( - effectiveParsed.type === "explicit" || - effectiveParsed.type === "org-all" - ) { - const effectiveOrg = await resolveEffectiveOrg(effectiveParsed.org); - if (effectiveOrg !== effectiveParsed.org) { - effectiveParsed = { ...effectiveParsed, org: effectiveOrg }; - } - setOrgProjectContext( - [effectiveOrg], - effectiveParsed.type === "explicit" ? [effectiveParsed.project] : [] - ); - } + effectiveParsed = await resolveOrgInParsed(effectiveParsed); const defaults = buildDefaultHandlers(config); const handlers: ModeHandlerMap = { ...defaults, ...overrides }; diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 9f1b97b0e..e02196a78 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -1640,10 +1640,31 @@ export async function resolveOrgProjectTarget( case "project-search": { const displaySlug = parsed.originalSlug ?? parsed.projectSlug; const isDisplayName = parsed.originalSlug !== undefined; - const { projects, orgs } = isDisplayName + + // Resolve DSN-style org identifiers (e.g. "o1081365" → "my-org") + // before using the org for filtering. + const scopedOrg = parsed.org + ? await resolveEffectiveOrg(parsed.org) + : undefined; + + const { projects: rawProjects, orgs: foundOrgs } = isDisplayName ? { projects: [], orgs: await listOrganizations() } : await findProjectsBySlug(parsed.projectSlug); + // When the caller provided an org (e.g. "org/My Project"), scope the + // search to that org instead of all accessible orgs. findProjectsBySlug + // fans out across every accessible org, so the matched projects must be + // filtered too — otherwise a slug that also exists in a different org + // could be returned (or flagged ambiguous) despite the explicit scope. + const orgs = + scopedOrg !== undefined + ? foundOrgs.filter((o) => o.slug === scopedOrg) + : foundOrgs; + const projects = + scopedOrg !== undefined + ? rawProjects.filter((p) => p.orgSlug === scopedOrg) + : rawProjects; + if (projects.length === 0) { const outcome = await triageProjectNotFound( parsed.projectSlug, @@ -1667,13 +1688,17 @@ export async function resolveOrgProjectTarget( }); } + const fallback = scopedOrg + ? [ + `No project with this name found in organization '${scopedOrg}'`, + `Check the organization slug or try: sentry project list ${scopedOrg}/`, + ] + : ["No project with this slug found in any accessible organization"]; throw new ResolutionError( `Project '${displaySlug}'`, "not found", `sentry ${commandName} /${parsed.projectSlug}`, - outcome.suggestions.length > 0 - ? outcome.suggestions - : ["No project with this slug found in any accessible organization"] + outcome.suggestions.length > 0 ? outcome.suggestions : fallback ); } @@ -1820,14 +1845,17 @@ export async function resolveTargetsFromParsedArg( } case "explicit": { - const projectId = await fetchProjectId(parsed.org, parsed.project); + // Resolve DSN-style org identifiers (e.g. "o1081365" → "my-org") before + // hitting the API, mirroring resolveOrgProjectTarget's explicit branch. + const org = await resolveEffectiveOrg(parsed.org); + const projectId = await fetchProjectId(org, parsed.project); return { targets: [ { - org: parsed.org, + org, project: parsed.project, projectId, - orgDisplay: parsed.org, + orgDisplay: org, projectDisplay: parsed.project, }, ], @@ -1835,20 +1863,23 @@ export async function resolveTargetsFromParsedArg( } case "org-all": { - const projects = await listProjects(parsed.org); + // Resolve DSN-style org identifiers (e.g. "o1081365" → "my-org") before + // listing projects, so "o123/" works the same as "my-org/". + const org = await resolveEffectiveOrg(parsed.org); + const projects = await listProjects(org); const targets: ResolvedTarget[] = projects.map((p) => ({ - org: parsed.org, + org, project: p.slug, projectId: toNumericId(p.id), - orgDisplay: parsed.org, + orgDisplay: org, projectDisplay: p.name, })); if (targets.length === 0) { throw new ResolutionError( - `Organization '${parsed.org}'`, + `Organization '${org}'`, "has no accessible projects", - `sentry project list ${parsed.org}/`, + `sentry project list ${org}/`, ["Check that you have access to projects in this organization"] ); } @@ -1857,7 +1888,7 @@ export async function resolveTargetsFromParsedArg( targets, footer: targets.length > 1 - ? `Showing results from ${targets.length} projects in ${parsed.org}` + ? `Showing results from ${targets.length} projects in ${org}` : undefined, }; } @@ -1872,15 +1903,42 @@ export async function resolveTargetsFromParsedArg( ); } - const { projects: matches, orgs } = await findProjectsBySlug( - parsed.projectSlug - ); + const displaySlug = parsed.originalSlug ?? parsed.projectSlug; + // When the input is a display name (originalSlug set, contains spaces), + // skip the slug-based API lookup and go straight to fuzzy matching. + const isDisplayName = parsed.originalSlug !== undefined; + + // Resolve DSN-style org identifiers before filtering. + const scopedOrg = parsed.org + ? await resolveEffectiveOrg(parsed.org) + : undefined; + + const { projects: rawMatches, orgs: foundOrgs } = isDisplayName + ? { projects: [], orgs: await listOrganizations() } + : await findProjectsBySlug(parsed.projectSlug); + + // When the caller provided an org (e.g. "org/My Project"), scope the + // search to that org instead of all accessible orgs. findProjectsBySlug + // fans out across every accessible org, so the matched projects must be + // filtered too — otherwise a slug that also exists in a different org + // could leak into a result that was explicitly scoped to one org. + const orgs = + scopedOrg !== undefined + ? foundOrgs.filter((o) => o.slug === scopedOrg) + : foundOrgs; + const matches = + scopedOrg !== undefined + ? rawMatches.filter((m) => m.orgSlug === scopedOrg) + : rawMatches; if (matches.length === 0) { - const isOrg = orgs.some((o) => o.slug === parsed.projectSlug); - if (isOrg) { - // Derive the base command from the usage hint (strip trailing placeholder). - // e.g. "sentry issue list /" → "sentry issue list" + const outcome = await triageProjectNotFound( + parsed.projectSlug, + orgs, + parsed.originalSlug + ); + + if (outcome.kind === "org-match") { const prefix = usageHint.split(" <")[0]; throw new ResolutionError( `'${parsed.projectSlug}'`, @@ -1893,19 +1951,31 @@ export async function resolveTargetsFromParsedArg( ); } - const similar = await findProjectsByPattern(parsed.projectSlug); - const suggestions: string[] = []; - if (similar.length > 0) { - const names = similar - .slice(0, 3) - .map((p) => `'${p.orgSlug}/${p.slug}'`); - suggestions.push(`Similar projects: ${names.join(", ")}`); + if (outcome.kind === "fuzzy-match") { + const projectId = await fetchProjectId(outcome.org, outcome.project); + const targets: ResolvedTarget[] = [ + { + org: outcome.org, + project: outcome.project, + projectId, + orgDisplay: outcome.org, + projectDisplay: outcome.project, + }, + ]; + setOrgProjectContext([outcome.org], [outcome.project]); + return { targets }; } - suggestions.push( - "No project with this slug found in any accessible organization" - ); + + const fallback = scopedOrg + ? [ + `No project with this name found in organization '${scopedOrg}'`, + `Check the organization slug or try: sentry project list ${scopedOrg}/`, + ] + : ["No project with this slug found in any accessible organization"]; + const suggestions = + outcome.suggestions.length > 0 ? outcome.suggestions : fallback; throw new ResolutionError( - `Project '${parsed.projectSlug}'`, + `Project '${displaySlug}'`, "not found", "sentry project list", suggestions diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index eb132954d..a134a705b 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -757,6 +757,64 @@ describe("handleProjectSearch", () => { expect(result.hint).toContain("matching platform 'rust'"); }); + test("scopedOrg only returns the matched project from that org, not a same-slug project elsewhere", async () => { + setOrgRegion("org-a", DEFAULT_SENTRY_URL); + setOrgRegion("org-b", DEFAULT_SENTRY_URL); + + // listOrganizations returns two orgs; each has a 'frontend' project. The + // bare-slug lookup fans out across both. With scopedOrg=org-a the result + // must only contain org-a's project, never org-b's. + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // getProject — /projects/{org}/{slug}/ + const projMatch = url.match(/\/projects\/([^/]+)\/frontend\//); + if (projMatch) { + return new Response( + JSON.stringify({ + id: projMatch[1] === "org-a" ? "1" : "2", + slug: "frontend", + name: "Frontend", + platform: "javascript", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // listOrganizations + if ( + url.includes("/organizations/") && + !url.includes("/projects/") && + !url.includes("/issues/") + ) { + return new Response( + JSON.stringify([ + { id: "10", slug: "org-a", name: "Org A" }, + { id: "20", slug: "org-b", name: "Org B" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + const result = await handleProjectSearch( + "frontend", + { limit: 30, json: false, fresh: false }, + { scopedOrg: "org-a" } + ); + + expect(result.items.every((i) => i.orgSlug === "org-a")).toBe(true); + expect(result.items.some((i) => i.orgSlug === "org-b")).toBe(false); + }); + test("respects --limit flag", async () => { setOrgRegion("org-a", DEFAULT_SENTRY_URL); setOrgRegion("org-b", DEFAULT_SENTRY_URL); diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 2fc032976..c16dc0b4c 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -216,21 +216,26 @@ describe("parseOrgProjectArg", () => { expect(() => parseOrgProjectArg("My Org/cli")).toThrow(ValidationError); }); - test("project with spaces in explicit mode throws ValidationError", () => { - expect(() => parseOrgProjectArg("sentry/My Project")).toThrow( - ValidationError - ); + test("project with spaces in explicit mode produces project-search with org", () => { + expect(parseOrgProjectArg("sentry/My Project")).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + org: "sentry", + }); }); test("org with spaces in org-all mode throws ValidationError", () => { expect(() => parseOrgProjectArg("My Org/")).toThrow(ValidationError); }); - test("org with underscores and project with spaces throws ValidationError", () => { - // Spaces in the project part of explicit mode hit validateResourceId. - expect(() => parseOrgProjectArg("my_org/My Project")).toThrow( - ValidationError - ); + test("org with underscores and project with spaces produces project-search with org", () => { + expect(parseOrgProjectArg("my_org/My Project")).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + org: "my_org", + }); }); test("does not throw for auto-detect", () => { @@ -1128,11 +1133,13 @@ describe("parseOrgProjectArg space handling (no normalization)", () => { }); }); - test("underscores with spaces in explicit mode throws ValidationError", () => { - // Spaces in the project part of explicit mode hit validateResourceId. - expect(() => parseOrgProjectArg("my_org/My Project")).toThrow( - ValidationError - ); + test("underscores with spaces in explicit mode produces project-search with org", () => { + expect(parseOrgProjectArg("my_org/My Project")).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + org: "my_org", + }); }); }); diff --git a/test/lib/org-list.test.ts b/test/lib/org-list.test.ts index a897d86cf..7d2354c71 100644 --- a/test/lib/org-list.test.ts +++ b/test/lib/org-list.test.ts @@ -717,6 +717,35 @@ describe("handleProjectSearch", () => { expect(result.hint).toContain("2 organizations"); }); + + test("scopes matches to the explicit org, ignoring a same-slug project in another org", async () => { + // findProjectsBySlug fans out across orgs and finds the slug in BOTH + // org-a (the requested scope) and org-b. With an explicit org the result + // must only fetch from org-a — never leak org-b. + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { orgSlug: "org-b", slug: "my-proj", id: "2", name: "My Project" }, + { orgSlug: "org-a", slug: "my-proj", id: "1", name: "My Project" }, + ], + orgs: [ + { slug: "org-a", name: "Org A" }, + { slug: "org-b", name: "Org B" }, + ], + }); + const listForOrg = vi.fn(() => + Promise.resolve([{ id: "1", name: "Widget" }]) + ); + const config = makeConfig({ listForOrg }); + + const result = await handleProjectSearch(config, "my-proj", { + flags: { limit: 10, json: false }, + org: "org-a", + }); + + expect(listForOrg).toHaveBeenCalledTimes(1); + expect(listForOrg).toHaveBeenCalledWith("org-a"); + expect(result.items.every((i) => i.orgSlug === "org-a")).toBe(true); + }); }); // --------------------------------------------------------------------------- diff --git a/test/lib/resolve-target-listing.test.ts b/test/lib/resolve-target-listing.test.ts index d0150dcff..191b4fd2d 100644 --- a/test/lib/resolve-target-listing.test.ts +++ b/test/lib/resolve-target-listing.test.ts @@ -36,7 +36,7 @@ vi.mock("../../src/lib/db/defaults.js", async (importOriginal) => { // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as defaults from "../../src/lib/db/defaults.js"; -import { setOrgRegion } from "../../src/lib/db/regions.js"; +import { setOrgRegion, setOrgRegions } from "../../src/lib/db/regions.js"; import { ContextError, ResolutionError } from "../../src/lib/errors.js"; vi.mock("../../src/lib/resolve-target.js", async (importOriginal) => { @@ -56,6 +56,7 @@ import { resolveOrgProjectFromArg, resolveOrgProjectTarget, resolveOrgsForListing, + resolveTargetsFromParsedArg, } from "../../src/lib/resolve-target.js"; const CWD = "/tmp/test-project"; @@ -369,3 +370,96 @@ describe("resolveOrgProjectFromArg", () => { expect(result).toEqual({ org: "auto-org", project: "auto-proj" }); }); }); + +// --------------------------------------------------------------------------- +// resolveTargetsFromParsedArg — org scoping & DSN-style org resolution +// --------------------------------------------------------------------------- + +describe("resolveTargetsFromParsedArg", () => { + let getProjectSpy: ReturnType; + let listProjectsSpy: ReturnType; + let findProjectsBySlugSpy: ReturnType; + + const OPTS = { cwd: CWD, usageHint: "sentry issue list /" }; + + beforeEach(() => { + getProjectSpy = vi.spyOn(apiClient, "getProject"); + listProjectsSpy = vi.spyOn(apiClient, "listProjects"); + findProjectsBySlugSpy = vi.spyOn(apiClient, "findProjectsBySlug"); + // Seed both a normal slug and a DSN-style numeric ID mapping so + // resolveEffectiveOrg resolves from cache without hitting the API. + setOrgRegion("my-org", DEFAULT_SENTRY_URL); + setOrgRegions([ + { slug: "real-org", regionUrl: DEFAULT_SENTRY_URL, orgId: "1081365" }, + ]); + }); + + afterEach(() => { + getProjectSpy.mockRestore(); + listProjectsSpy.mockRestore(); + findProjectsBySlugSpy.mockRestore(); + }); + + test("explicit: resolves DSN-style org id to the real slug", async () => { + getProjectSpy.mockResolvedValue({ id: "42", slug: "my-proj" }); + + const result = await resolveTargetsFromParsedArg( + { type: "explicit", org: "o1081365", project: "my-proj" }, + OPTS + ); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0]).toMatchObject({ + org: "real-org", + project: "my-proj", + projectId: 42, + }); + // The API lookup must use the resolved slug, not the raw DSN id. + expect(getProjectSpy).toHaveBeenCalledWith("real-org", "my-proj"); + }); + + test("org-all: resolves DSN-style org id before listing projects", async () => { + listProjectsSpy.mockResolvedValue([ + { id: "1", slug: "p1", name: "P1" }, + { id: "2", slug: "p2", name: "P2" }, + ]); + + const result = await resolveTargetsFromParsedArg( + { type: "org-all", org: "o1081365" }, + OPTS + ); + + expect(listProjectsSpy).toHaveBeenCalledWith("real-org"); + expect(result.targets).toHaveLength(2); + for (const t of result.targets) { + expect(t.org).toBe("real-org"); + } + }); + + test("project-search: scopes recovery to explicit org, ignoring a same-slug project in another org", async () => { + // A bare-slug lookup fans out across orgs and finds the slug in BOTH + // org-a (the requested scope) and org-b. The result must only include + // the org-a match — never leak org-b. + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { orgSlug: "org-b", slug: "my-proj", name: "My Project" }, + { orgSlug: "my-org", slug: "my-proj", name: "My Project" }, + ], + orgs: [ + { slug: "org-b", name: "Org B" }, + { slug: "my-org", name: "My Org" }, + ], + }); + + const result = await resolveTargetsFromParsedArg( + { type: "project-search", projectSlug: "my-proj", org: "my-org" }, + OPTS + ); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0]).toMatchObject({ + org: "my-org", + project: "my-proj", + }); + }); +});