diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 68c3cc350..fc108515d 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -107,6 +107,7 @@ async function handleExistingAuth(force: boolean): Promise { } export const loginCommand = buildCommand({ + auth: false, docs: { brief: "Authenticate with Sentry", fullDescription: diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 5a4588ff8..669be0ec6 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -28,6 +28,7 @@ export type LogoutResult = { }; export const logoutCommand = buildCommand({ + auth: false, docs: { brief: "Log out of Sentry", fullDescription: diff --git a/src/commands/auth/refresh.ts b/src/commands/auth/refresh.ts index 73d7127a2..cee651b5c 100644 --- a/src/commands/auth/refresh.ts +++ b/src/commands/auth/refresh.ts @@ -39,6 +39,7 @@ function formatRefreshResult(data: RefreshOutput): string { } export const refreshCommand = buildCommand({ + auth: false, docs: { brief: "Refresh your authentication token", fullDescription: ` diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 2f6de80b5..3592103ee 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -138,6 +138,7 @@ async function verifyCredentials(): Promise { } export const statusCommand = buildCommand({ + auth: false, docs: { brief: "View authentication status", fullDescription: diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index 190b6cf7a..04d8090d8 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -27,7 +27,6 @@ export const tokenCommand = buildCommand({ if (!token) { throw new AuthError("not_authenticated"); } - return yield new CommandOutput(token); }, }); diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index b7ae06d87..2051b64e0 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -9,9 +9,7 @@ import type { SentryContext } from "../../context.js"; import { getCurrentUser } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; -import { isAuthenticated } from "../../lib/db/auth.js"; import { setUserInfo } from "../../lib/db/user.js"; -import { AuthError } from "../../lib/errors.js"; import { formatUserIdentity } from "../../lib/formatters/index.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { @@ -46,10 +44,6 @@ export const whoamiCommand = buildCommand({ async *func(this: SentryContext, flags: WhoamiFlags) { applyFreshFlag(flags); - if (!isAuthenticated()) { - throw new AuthError("not_authenticated"); - } - const user = await getCurrentUser(); // Keep cached user info up to date. Non-fatal: display must succeed even diff --git a/src/commands/cli/feedback.ts b/src/commands/cli/feedback.ts index 55d115f33..90e7a6d5e 100644 --- a/src/commands/cli/feedback.ts +++ b/src/commands/cli/feedback.ts @@ -25,6 +25,7 @@ export type FeedbackResult = { }; export const feedbackCommand = buildCommand({ + auth: false, docs: { brief: "Send feedback about the CLI", fullDescription: diff --git a/src/commands/cli/fix.ts b/src/commands/cli/fix.ts index e76ca32b5..99d8ed008 100644 --- a/src/commands/cli/fix.ts +++ b/src/commands/cli/fix.ts @@ -650,6 +650,7 @@ function safeHandleSchemaIssues( } export const fixCommand = buildCommand({ + auth: false, docs: { brief: "Diagnose and repair CLI database issues", fullDescription: diff --git a/src/commands/cli/setup.ts b/src/commands/cli/setup.ts index c08f156de..5e264d5b2 100644 --- a/src/commands/cli/setup.ts +++ b/src/commands/cli/setup.ts @@ -428,6 +428,7 @@ async function runConfigurationSteps(opts: ConfigStepOptions) { } export const setupCommand = buildCommand({ + auth: false, docs: { brief: "Configure shell integration", fullDescription: diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 98e9a2efb..f046b9ff8 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -643,6 +643,7 @@ async function buildCheckResultWithChangelog(opts: { } export const upgradeCommand = buildCommand({ + auth: false, docs: { brief: "Update the Sentry CLI to the latest version", fullDescription: diff --git a/src/commands/help.ts b/src/commands/help.ts index 3330df59b..bdf9c8efa 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -20,6 +20,7 @@ import { } from "../lib/help.js"; export const helpCommand = buildCommand({ + auth: false, docs: { brief: "Display help for a command", fullDescription: diff --git a/src/commands/schema.ts b/src/commands/schema.ts index c47b0951e..93a1f57fe 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -260,6 +260,7 @@ type SchemaFlags = { }; export const schemaCommand = buildCommand({ + auth: false, docs: { brief: "Browse the Sentry API schema", fullDescription: diff --git a/src/lib/command.ts b/src/lib/command.ts index cc62d83c4..95e59b890 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -37,7 +37,8 @@ import { numberParser as stricliNumberParser, } from "@stricli/core"; import type { Writer } from "../types/index.js"; -import { CliError, OutputError } from "./errors.js"; +import { getAuthConfig } from "./db/auth.js"; +import { AuthError, CliError, OutputError } from "./errors.js"; import { warning } from "./formatters/colors.js"; import { parseFieldsList } from "./formatters/json.js"; import { @@ -150,6 +151,19 @@ type LocalCommandBuilderArguments< */ // biome-ignore lint/suspicious/noExplicitAny: Variance erasure — OutputConfig.human is contravariant in T, but the builder erases T because it doesn't know the output type. Using `any` allows commands to declare OutputConfig while the wrapper handles it generically. readonly output?: OutputConfig; + /** + * Whether the command requires authentication. Defaults to `true`. + * + * When `true` (the default), the command throws `AuthError("not_authenticated")` + * before executing if no credentials exist at all (no token or refresh token + * in the DB or env vars). Expired tokens with a valid refresh token pass the + * guard — the API client handles silent refresh. The auto-auth middleware in + * `cli.ts` catches the error and triggers the login flow. + * + * Set to `false` for commands that intentionally work without a token + * (e.g. `auth login`, `auth logout`, `auth status`, `help`, `cli upgrade`). + */ + readonly auth?: boolean; }; // --------------------------------------------------------------------------- @@ -252,6 +266,10 @@ export function applyLoggingFlags( * 4. Captures flag values and positional arguments as Sentry telemetry context * 5. When `output` has an {@link OutputConfig}, injects `--json` and `--fields` * flags, pre-parses `--fields`, and auto-renders the command's `{ data }` return + * 6. Enforces authentication by default — throws `AuthError("not_authenticated")` + * before the command runs if no credentials exist at all (expired tokens with + * a refresh token pass through so the API client can silently refresh). Opt out with `auth: false` + * for commands that intentionally work without a token (e.g. `auth login`, `help`) * * When a command already defines its own `verbose` flag (e.g. the `api` command * uses `--verbose` for HTTP request/response output), the injected `VERBOSE_FLAG` @@ -324,6 +342,7 @@ export function buildCommand< ): Command { const originalFunc = builderArgs.func; const outputConfig = builderArgs.output; + const requiresAuth = builderArgs.auth !== false; // Merge logging flags into the command's flag definitions. // Quoted keys produce kebab-case CLI flags: "log-level" → --log-level @@ -557,7 +576,16 @@ export function buildCommand< // Iterate the generator using manual .next() instead of for-await-of // so we can capture the return value (done: true result). The return // value carries the final `hint` — for-await-of discards it. + // + // Auth guard is inside the try block so that maybeRecoverWithHelp can + // intercept the AuthError when "help" appears as a positional arg (e.g. + // `sentry issue list help`). Without this, the auth prompt would fire + // before the help-recovery path could show the command's help text. try { + if (requiresAuth && !getAuthConfig()) { + throw new AuthError("not_authenticated"); + } + const generator = originalFunc.call( this, cleanFlags as FLAGS, diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index d814ed97c..5ccfbbbe3 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -464,6 +464,7 @@ export function buildListCommand< readonly func: ListCommandFunction; // biome-ignore lint/suspicious/noExplicitAny: OutputConfig is generic but type is erased at the builder level readonly output?: OutputConfig; + readonly auth?: boolean; }, options?: ListCommandOptions ): Command { diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 027f59e4d..c9567c39b 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -275,7 +275,7 @@ const LIBRARY_EXCLUDED_INTEGRATIONS = new Set([ ...EXCLUDED_INTEGRATIONS, "OnUncaughtException", // process.on('uncaughtException') "OnUnhandledRejection", // process.on('unhandledRejection') - "ProcessSession", // process.on('beforeExit') + "ProcessSession", // process.on('beforeExit') — anonymous handler, no cleanup "Http", // diagnostics_channel + trace headers "NodeFetch", // diagnostics_channel + trace headers "FunctionToString", // wraps Function.prototype.toString @@ -355,6 +355,13 @@ export function initSentry( const libraryMode = options?.libraryMode ?? false; const environment = getEnv().NODE_ENV ?? "development"; + // Close the previous client to clean up its internal timers and beforeExit + // handlers (client report flusher interval, log flush listener). Without + // this, re-initializing the SDK (e.g., in tests) leaks setInterval handles + // that keep the event loop alive and prevent the process from exiting. + // close(0) removes listeners synchronously; we don't need to await the flush. + Sentry.getClient()?.close(0); + const client = Sentry.init({ dsn: SENTRY_CLI_DSN, enabled, @@ -374,10 +381,12 @@ export function initSentry( }, environment, // Enable Sentry structured logs for non-exception telemetry (e.g., unexpected input warnings). - // Disabled in library mode — logs use timers/beforeExit that pollute the host process. - enableLogs: !libraryMode, - // Disable client reports in library mode — they use timers/beforeExit. - ...(libraryMode && { sendClientReports: false }), + // Disabled when telemetry is off or in library mode — the SDK registers + // beforeExit handlers for log flushing that keep the event loop alive. + enableLogs: enabled && !libraryMode, + // Disable client reports when telemetry is off or in library mode — the SDK + // registers a setInterval + beforeExit handler that keep the event loop alive. + ...((libraryMode || !enabled) && { sendClientReports: false }), // Sample all events for CLI telemetry (low volume) tracesSampleRate: 1, sampleRate: 1, @@ -405,6 +414,12 @@ export function initSentry( }, }); + // Always remove our own previous handler on re-init. + if (currentBeforeExitHandler) { + process.removeListener("beforeExit", currentBeforeExitHandler); + currentBeforeExitHandler = null; + } + if (client?.getOptions().enabled) { const isBun = typeof process.versions.bun !== "undefined"; const runtime = isBun ? "bun" : "node"; @@ -442,13 +457,7 @@ export function initSentry( // Skipped in library mode — the host owns the process lifecycle. // The library entry point calls client.flush() manually after completion. // - // Replace previous handler on re-init (e.g., auto-login retry calls - // withTelemetry → initSentry twice) to avoid duplicate handlers with - // independent re-entry guards and stale client references. if (!libraryMode) { - if (currentBeforeExitHandler) { - process.removeListener("beforeExit", currentBeforeExitHandler); - } currentBeforeExitHandler = createBeforeExitHandler(client); process.on("beforeExit", currentBeforeExitHandler); } diff --git a/test/commands/auth/logout.test.ts b/test/commands/auth/logout.test.ts index 8953cb448..77d0c60aa 100644 --- a/test/commands/auth/logout.test.ts +++ b/test/commands/auth/logout.test.ts @@ -126,6 +126,8 @@ describe("logoutCommand.func", () => { isAuthenticatedSpy.mockReturnValue(true); isEnvTokenActiveSpy.mockReturnValue(true); // Set env var directly — getActiveEnvVarName() reads env vars via getEnvToken() + // Clear SENTRY_AUTH_TOKEN so SENTRY_TOKEN takes priority + delete process.env.SENTRY_AUTH_TOKEN; process.env.SENTRY_TOKEN = "sntrys_token_456"; const { context } = createContext(); diff --git a/test/commands/auth/refresh.test.ts b/test/commands/auth/refresh.test.ts index 94c786a26..189ebcc2e 100644 --- a/test/commands/auth/refresh.test.ts +++ b/test/commands/auth/refresh.test.ts @@ -85,6 +85,8 @@ describe("refreshCommand.func", () => { test("env token (SENTRY_TOKEN): throws AuthError with SENTRY_TOKEN in message", async () => { isEnvTokenActiveSpy.mockReturnValue(true); // Set env var directly — getActiveEnvVarName() reads env vars via getEnvToken() + // Clear SENTRY_AUTH_TOKEN so SENTRY_TOKEN takes priority + delete process.env.SENTRY_AUTH_TOKEN; process.env.SENTRY_TOKEN = "sntrys_token_456"; const { context } = createContext(); diff --git a/test/commands/auth/whoami.test.ts b/test/commands/auth/whoami.test.ts index dd44e52dc..d18caa1a9 100644 --- a/test/commands/auth/whoami.test.ts +++ b/test/commands/auth/whoami.test.ts @@ -66,12 +66,17 @@ function createContext() { describe("whoamiCommand.func", () => { let isAuthenticatedSpy: ReturnType; + let getAuthConfigSpy: ReturnType; let getCurrentUserSpy: ReturnType; let setUserInfoSpy: ReturnType; let func: WhoamiFunc; beforeEach(async () => { isAuthenticatedSpy = spyOn(dbAuth, "isAuthenticated"); + getAuthConfigSpy = spyOn(dbAuth, "getAuthConfig").mockReturnValue({ + token: "sntrys_test", + source: "config", + }); getCurrentUserSpy = spyOn(apiClient, "getCurrentUser"); setUserInfoSpy = spyOn(dbUser, "setUserInfo"); func = (await whoamiCommand.loader()) as unknown as WhoamiFunc; @@ -79,11 +84,18 @@ describe("whoamiCommand.func", () => { afterEach(() => { isAuthenticatedSpy.mockRestore(); + getAuthConfigSpy.mockRestore(); getCurrentUserSpy.mockRestore(); setUserInfoSpy.mockRestore(); }); describe("unauthenticated", () => { + beforeEach(() => { + // Override the auth mock so buildCommand's auth guard + // sees no credentials — this tests the unauthenticated path end-to-end. + getAuthConfigSpy.mockReturnValue(undefined); + }); + test("throws AuthError(not_authenticated) when no token stored", async () => { isAuthenticatedSpy.mockReturnValue(false); diff --git a/test/commands/cli.test.ts b/test/commands/cli.test.ts index 745e40c67..817d7ee54 100644 --- a/test/commands/cli.test.ts +++ b/test/commands/cli.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { feedbackCommand } from "../../src/commands/cli/feedback.js"; import type { UpgradeResult } from "../../src/commands/cli/upgrade.js"; import { upgradeCommand } from "../../src/commands/cli/upgrade.js"; +import { useAuthMock } from "../helpers.js"; /** * Create a mock context with a process.stderr.write spy for capturing @@ -62,6 +63,8 @@ function createMockContext(overrides: Partial<{ execPath: string }> = {}): { }; } +useAuthMock(); + describe("feedbackCommand.func", () => { test("throws ValidationError for empty message", async () => { // Access func through loader diff --git a/test/commands/dashboard/create.test.ts b/test/commands/dashboard/create.test.ts index 75d7a0df0..aed6c1a24 100644 --- a/test/commands/dashboard/create.test.ts +++ b/test/commands/dashboard/create.test.ts @@ -22,6 +22,7 @@ import { ContextError, ValidationError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { DashboardDetail } from "../../../src/types/dashboard.js"; +import { useAuthMock } from "../../helpers.js"; // --------------------------------------------------------------------------- // Helpers @@ -54,6 +55,8 @@ const sampleDashboard: DashboardDetail = { // Tests // --------------------------------------------------------------------------- +useAuthMock(); + describe("dashboard create", () => { let createDashboardSpy: ReturnType; let resolveOrgSpy: ReturnType; diff --git a/test/commands/dashboard/list.test.ts b/test/commands/dashboard/list.test.ts index 824efcc98..fa7ff5124 100644 --- a/test/commands/dashboard/list.test.ts +++ b/test/commands/dashboard/list.test.ts @@ -36,6 +36,7 @@ import * as polling from "../../../src/lib/polling.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { DashboardListItem } from "../../../src/types/dashboard.js"; +import { useAuthMock } from "../../helpers.js"; // --------------------------------------------------------------------------- // Helpers @@ -104,6 +105,8 @@ const DASHBOARD_C: DashboardListItem = { // Tests // --------------------------------------------------------------------------- +useAuthMock(); + describe("dashboard list command", () => { let listDashboardsPaginatedSpy: ReturnType; let resolveOrgSpy: ReturnType; diff --git a/test/commands/dashboard/widget/add.test.ts b/test/commands/dashboard/widget/add.test.ts index d4f3027ce..c9338caad 100644 --- a/test/commands/dashboard/widget/add.test.ts +++ b/test/commands/dashboard/widget/add.test.ts @@ -22,6 +22,7 @@ import { ValidationError } from "../../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../../src/lib/resolve-target.js"; import type { DashboardDetail } from "../../../../src/types/dashboard.js"; +import { useAuthMock } from "../../../helpers.js"; // --------------------------------------------------------------------------- // Helpers @@ -85,6 +86,8 @@ const sampleDashboard: DashboardDetail = { // Tests // --------------------------------------------------------------------------- +useAuthMock(); + describe("dashboard widget add", () => { let getDashboardSpy: ReturnType; let updateDashboardSpy: ReturnType; diff --git a/test/commands/dashboard/widget/delete.test.ts b/test/commands/dashboard/widget/delete.test.ts index e2052e8d7..18fd86316 100644 --- a/test/commands/dashboard/widget/delete.test.ts +++ b/test/commands/dashboard/widget/delete.test.ts @@ -22,6 +22,7 @@ import { ValidationError } from "../../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../../src/lib/resolve-target.js"; import type { DashboardDetail } from "../../../../src/types/dashboard.js"; +import { useAuthMock } from "../../../helpers.js"; // --------------------------------------------------------------------------- // Helpers @@ -85,6 +86,8 @@ const sampleDashboard: DashboardDetail = { // Tests // --------------------------------------------------------------------------- +useAuthMock(); + describe("dashboard widget delete", () => { let getDashboardSpy: ReturnType; let updateDashboardSpy: ReturnType; diff --git a/test/commands/dashboard/widget/edit.test.ts b/test/commands/dashboard/widget/edit.test.ts index 871dedcc2..0b190a0ad 100644 --- a/test/commands/dashboard/widget/edit.test.ts +++ b/test/commands/dashboard/widget/edit.test.ts @@ -22,6 +22,7 @@ import { ValidationError } from "../../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../../src/lib/resolve-target.js"; import type { DashboardDetail } from "../../../../src/types/dashboard.js"; +import { useAuthMock } from "../../../helpers.js"; // --------------------------------------------------------------------------- // Helpers @@ -85,6 +86,8 @@ const sampleDashboard: DashboardDetail = { // Tests // --------------------------------------------------------------------------- +useAuthMock(); + describe("dashboard widget edit", () => { let getDashboardSpy: ReturnType; let updateDashboardSpy: ReturnType; diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 59c21c559..809a76af7 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -42,6 +42,9 @@ import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as spanTree from "../../../src/lib/span-tree.js"; import type { SentryEvent } from "../../../src/types/index.js"; +import { useAuthMock } from "../../helpers.js"; + +useAuthMock(); describe("parsePositionalArgs", () => { describe("single argument (event ID only)", () => { diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index 59af0ff7d..552a2eef2 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -17,6 +17,7 @@ import * as prefetchNs from "../../src/lib/init/prefetch.js"; import { resetPrefetch } from "../../src/lib/init/prefetch.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as wizardRunner from "../../src/lib/init/wizard-runner.js"; +import { useAuthMock } from "../helpers.js"; /** Minimal org shape for mock returns */ const MOCK_ORG = { id: "1", slug: "resolved-org", name: "Resolved Org" }; @@ -85,6 +86,8 @@ afterEach(() => { resetPrefetch(); }); +useAuthMock(); + describe("init command func", () => { // ── Features parsing ────────────────────────────────────────────────── diff --git a/test/commands/log/list.test.ts b/test/commands/log/list.test.ts index 43ecaab2a..8beedb87a 100644 --- a/test/commands/log/list.test.ts +++ b/test/commands/log/list.test.ts @@ -30,6 +30,8 @@ import { import { listCommand } from "../../../src/commands/log/list.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as dbAuth from "../../../src/lib/db/auth.js"; import { AuthError, ContextError, @@ -46,6 +48,7 @@ import * as traceTarget from "../../../src/lib/trace-target.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as versionCheck from "../../../src/lib/version-check.js"; import type { SentryLog, TraceLog } from "../../../src/types/sentry.js"; +import { useAuthMock } from "../../helpers.js"; // ============================================================================ // Helpers @@ -237,10 +240,29 @@ const newerLogs: SentryLog[] = [ }, ]; +// ============================================================================ +// Auth setup — mock getAuthConfig for all tests (auth guard added in #611) +// ============================================================================ + +let getAuthConfigSpy: ReturnType; + +beforeEach(() => { + getAuthConfigSpy = spyOn(dbAuth, "getAuthConfig").mockReturnValue({ + token: "sntrys_test", + source: "config", + }); +}); + +afterEach(() => { + getAuthConfigSpy.mockRestore(); +}); + // ============================================================================ // Standard mode (project-scoped, no trace-id positional) // ============================================================================ +useAuthMock(); + describe("listCommand.func — standard mode", () => { let listLogsSpy: ReturnType; let resolveOrgProjectSpy: ReturnType; @@ -837,7 +859,14 @@ describe("listCommand.func — flag validation", () => { test("allows --sort newest with --follow", async () => { // Should not throw ValidationError — the error (if any) comes from - // downstream resolution, not flag validation. + // downstream resolution, not flag validation. Mock resolution to reject + // with a non-ValidationError so we can verify flag validation passed. + const resolveOrgProjectSpy = spyOn( + resolveTarget, + "resolveOrgProjectFromArg" + ).mockRejectedValueOnce( + new ContextError("Organization", "sentry log list") + ); const { context } = createMockContext(); const func = await listCommand.loader(); await expect( @@ -847,6 +876,7 @@ describe("listCommand.func — flag validation", () => { "my-org/my-project" ) ).rejects.not.toThrow(ValidationError); + resolveOrgProjectSpy.mockRestore(); }); }); diff --git a/test/commands/log/view.func.test.ts b/test/commands/log/view.func.test.ts index c6bbfa5b9..0818d73ac 100644 --- a/test/commands/log/view.func.test.ts +++ b/test/commands/log/view.func.test.ts @@ -24,6 +24,7 @@ import { ContextError, ValidationError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { DetailedSentryLog } from "../../../src/types/sentry.js"; +import { useAuthMock } from "../../helpers.js"; const ID1 = "aaaa1111bbbb2222cccc3333dddd4444"; const ID2 = "1111222233334444555566667777aaaa"; @@ -64,6 +65,8 @@ function createMockContext() { }; } +useAuthMock(); + describe("viewCommand.func", () => { let getLogsSpy: ReturnType; let resolveOrgAndProjectSpy: ReturnType; diff --git a/test/commands/log/view.test.ts b/test/commands/log/view.test.ts index 5be7e3c31..9ad303ca2 100644 --- a/test/commands/log/view.test.ts +++ b/test/commands/log/view.test.ts @@ -32,12 +32,15 @@ import { } from "../../../src/lib/errors.js"; import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; import type { DetailedSentryLog } from "../../../src/types/index.js"; +import { useAuthMock } from "../../helpers.js"; /** A valid 32-char hex log ID for tests */ const ID1 = "968c763c740cfda8b6728f27fb9e9b01"; const ID2 = "aaaa1111bbbb2222cccc3333dddd4444"; const ID3 = "1234567890abcdef1234567890abcdef"; +useAuthMock(); + describe("parsePositionalArgs", () => { describe("single argument (log ID only)", () => { test("parses single 32-char hex log ID", () => { diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index e7030faa1..9a5154cab 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -27,6 +27,7 @@ import { // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { SentryProject, SentryTeam } from "../../../src/types/index.js"; +import { useAuthMock } from "../../helpers.js"; const sampleTeam: SentryTeam = { id: "1", @@ -64,6 +65,8 @@ function createMockContext() { }; } +useAuthMock(); + describe("project create", () => { let listTeamsSpy: ReturnType; let createProjectSpy: ReturnType; diff --git a/test/commands/project/delete.test.ts b/test/commands/project/delete.test.ts index 878886b8c..5336cacfe 100644 --- a/test/commands/project/delete.test.ts +++ b/test/commands/project/delete.test.ts @@ -26,6 +26,7 @@ import { ApiError, ContextError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { SentryProject } from "../../../src/types/index.js"; +import { useAuthMock } from "../../helpers.js"; const sampleProject: SentryProject = { id: "999", @@ -52,6 +53,8 @@ function createMockContext() { }; } +useAuthMock(); + describe("project delete", () => { let getProjectSpy: ReturnType; let deleteProjectSpy: ReturnType; diff --git a/test/commands/project/view.func.test.ts b/test/commands/project/view.func.test.ts index c2524ebe3..d71f21041 100644 --- a/test/commands/project/view.func.test.ts +++ b/test/commands/project/view.func.test.ts @@ -24,6 +24,7 @@ import { AuthError, ContextError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { ProjectKey, SentryProject } from "../../../src/types/sentry.js"; +import { useAuthMock } from "../../helpers.js"; const sampleProject: SentryProject = { id: "42", @@ -55,6 +56,8 @@ function createMockContext() { }; } +useAuthMock(); + describe("viewCommand.func", () => { let getProjectSpy: ReturnType; let getProjectKeysSpy: ReturnType; diff --git a/test/commands/repo/list.test.ts b/test/commands/repo/list.test.ts index 96fe11b15..f876e7700 100644 --- a/test/commands/repo/list.test.ts +++ b/test/commands/repo/list.test.ts @@ -28,6 +28,7 @@ import { ValidationError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { SentryRepository } from "../../../src/types/sentry.js"; +import { useAuthMock } from "../../helpers.js"; // Sample test data const sampleRepos: SentryRepository[] = [ @@ -69,6 +70,8 @@ function createMockContext(cwd = "/tmp") { }; } +useAuthMock(); + describe("listCommand.func — project-search (bare slug)", () => { let listRepositoriesSpy: ReturnType; let findProjectsBySlugSpy: ReturnType; diff --git a/test/commands/span/list.test.ts b/test/commands/span/list.test.ts index 258f0bb81..fd8266493 100644 --- a/test/commands/span/list.test.ts +++ b/test/commands/span/list.test.ts @@ -28,6 +28,7 @@ import * as apiClient from "../../../src/lib/api-client.js"; import * as paginationDb from "../../../src/lib/db/pagination.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { useAuthMock } from "../../helpers.js"; const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; @@ -40,6 +41,8 @@ const _paginationDbRef = paginationDb; // parseSort // ============================================================================ +useAuthMock(); + describe("parseSort", () => { test("accepts 'date'", () => { expect(parseSort("date")).toBe("date"); diff --git a/test/commands/span/view.test.ts b/test/commands/span/view.test.ts index 3e611a221..a11e11a07 100644 --- a/test/commands/span/view.test.ts +++ b/test/commands/span/view.test.ts @@ -24,11 +24,14 @@ import { ContextError, ValidationError } from "../../../src/lib/errors.js"; import { validateSpanId } from "../../../src/lib/hex-id.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { useAuthMock } from "../../helpers.js"; const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; const VALID_SPAN_ID = "a1b2c3d4e5f67890"; const VALID_SPAN_ID_2 = "1234567890abcdef"; +useAuthMock(); + describe("validateSpanId", () => { test("accepts valid 16-char lowercase hex", () => { expect(validateSpanId("a1b2c3d4e5f67890")).toBe("a1b2c3d4e5f67890"); diff --git a/test/commands/team/list.test.ts b/test/commands/team/list.test.ts index 01f717ef8..1995b14ec 100644 --- a/test/commands/team/list.test.ts +++ b/test/commands/team/list.test.ts @@ -28,6 +28,7 @@ import { ValidationError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { SentryTeam } from "../../../src/types/sentry.js"; +import { useAuthMock } from "../../helpers.js"; // Sample test data const sampleTeams: SentryTeam[] = [ @@ -65,6 +66,8 @@ function createMockContext(cwd = "/tmp") { }; } +useAuthMock(); + describe("listCommand.func — project-search (bare slug)", () => { let listProjectTeamsSpy: ReturnType; let findProjectsBySlugSpy: ReturnType; diff --git a/test/commands/trace/list.test.ts b/test/commands/trace/list.test.ts index 1fc55cf27..e4148a780 100644 --- a/test/commands/trace/list.test.ts +++ b/test/commands/trace/list.test.ts @@ -30,6 +30,7 @@ import { ContextError, ResolutionError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { TransactionListItem } from "../../../src/types/sentry.js"; +import { useAuthMock } from "../../helpers.js"; // Reference paginationDb early to prevent import stripping by auto-organize const _paginationDbRef = paginationDb; @@ -38,6 +39,8 @@ const _paginationDbRef = paginationDb; // validateLimit (shared utility from arg-parsing.ts) // ============================================================================ +useAuthMock(); + describe("validateLimit", () => { test("returns number for valid value", () => { expect(validateLimit("1", 1, 1000)).toBe(1); diff --git a/test/commands/trace/logs.test.ts b/test/commands/trace/logs.test.ts index 530d5f6ea..efb19cb4f 100644 --- a/test/commands/trace/logs.test.ts +++ b/test/commands/trace/logs.test.ts @@ -29,6 +29,7 @@ import * as polling from "../../../src/lib/polling.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { TraceLog } from "../../../src/types/sentry.js"; +import { useAuthMock } from "../../helpers.js"; // Note: parseTraceTarget parsing tests are in test/lib/trace-target.test.ts @@ -99,6 +100,8 @@ function collectMockOutput( .join(""); } +useAuthMock(); + describe("logsCommand.func", () => { let listTraceLogsSpy: ReturnType; let resolveOrgSpy: ReturnType; diff --git a/test/commands/trace/view.func.test.ts b/test/commands/trace/view.func.test.ts index 628522ebc..33a2443af 100644 --- a/test/commands/trace/view.func.test.ts +++ b/test/commands/trace/view.func.test.ts @@ -31,11 +31,14 @@ import { ContextError, ValidationError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { TraceSpan } from "../../../src/types/sentry.js"; +import { useAuthMock } from "../../helpers.js"; // ============================================================================ // formatTraceView // ============================================================================ +useAuthMock(); + describe("formatTraceView", () => { const mockSummary = { traceId: "abc123", diff --git a/test/commands/trial/list.test.ts b/test/commands/trial/list.test.ts index 8d40e3dbf..4aaf9c7b2 100644 --- a/test/commands/trial/list.test.ts +++ b/test/commands/trial/list.test.ts @@ -24,6 +24,7 @@ import type { CustomerTrialInfo, ProductTrial, } from "../../../src/types/index.js"; +import { useAuthMock } from "../../helpers.js"; // --------------------------------------------------------------------------- // Helpers @@ -103,6 +104,8 @@ const EXPIRED_TRIAL: ProductTrial = { // Tests // --------------------------------------------------------------------------- +useAuthMock(); + describe("trial list command", () => { let getCustomerTrialInfoSpy: ReturnType; let resolveOrgSpy: ReturnType; diff --git a/test/commands/trial/start.test.ts b/test/commands/trial/start.test.ts index a3591d633..3317ae3a0 100644 --- a/test/commands/trial/start.test.ts +++ b/test/commands/trial/start.test.ts @@ -29,6 +29,7 @@ import type { CustomerTrialInfo, ProductTrial, } from "../../../src/types/index.js"; +import { useAuthMock } from "../../helpers.js"; // --------------------------------------------------------------------------- // Helpers @@ -65,6 +66,8 @@ const MOCK_TRIAL: ProductTrial = { // Tests // --------------------------------------------------------------------------- +useAuthMock(); + describe("trial start command", () => { let getProductTrialsSpy: ReturnType; let startProductTrialSpy: ReturnType; diff --git a/test/helpers.ts b/test/helpers.ts index 3d7b4429b..9ce616e4c 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -4,10 +4,12 @@ * Shared utilities for test setup and teardown. */ -import { afterEach, beforeEach } from "bun:test"; +import { afterEach, beforeEach, spyOn } from "bun:test"; import { mkdirSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as dbAuth from "../src/lib/db/auth.js"; import { CONFIG_DIR_ENV_VAR, closeDatabase } from "../src/lib/db/index.js"; // biome-ignore lint/performance/noBarrelFile: re-exporting a single constant, not a barrel @@ -126,3 +128,31 @@ export function useTestConfigDir( return () => dir; } + +/** + * Mock getAuthConfig so buildCommand's auth guard passes. + * + * The auth guard (added in #611) calls getAuthConfig() before every command. + * Tests that invoke command functions need auth to be present. This helper + * registers beforeEach/afterEach hooks that mock getAuthConfig to return + * a valid config. + * + * Tests that need to verify unauthenticated behavior can override the mock + * inside a describe block. + * + * Must be called at module scope or inside a describe() block. + */ +export function useAuthMock(): void { + let spy: ReturnType; + + beforeEach(() => { + spy = spyOn(dbAuth, "getAuthConfig").mockReturnValue({ + token: "sntrys_test", + source: "config", + }); + }); + + afterEach(() => { + spy.mockRestore(); + }); +} diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index e0883d380..2495186c3 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -76,6 +76,7 @@ describe("buildCommand", () => { test("builds a valid command object", () => { const command = buildCommand({ docs: { brief: "Test command" }, + auth: false, parameters: { flags: { verbose: { kind: "boolean", brief: "Verbose", default: false }, @@ -91,6 +92,7 @@ describe("buildCommand", () => { test("handles commands with empty parameters", () => { const command = buildCommand({ docs: { brief: "Simple command" }, + auth: false, parameters: {}, async *func() { // no-op @@ -128,6 +130,7 @@ describe("buildCommand telemetry integration", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, parameters: { flags: { verbose: { kind: "boolean", brief: "Verbose", default: false }, @@ -168,6 +171,7 @@ describe("buildCommand telemetry integration", () => { test("skips false boolean flags in telemetry", async () => { const command = buildCommand<{ json: boolean }, [], TestContext>({ docs: { brief: "Test" }, + auth: false, parameters: { flags: { json: { kind: "boolean", brief: "JSON output", default: false }, @@ -199,6 +203,7 @@ describe("buildCommand telemetry integration", () => { const command = buildCommand, [string], TestContext>({ docs: { brief: "Test" }, + auth: false, parameters: { positional: { kind: "tuple", @@ -236,6 +241,7 @@ describe("buildCommand telemetry integration", () => { const command = buildCommand, [], TestContext>({ docs: { brief: "Test" }, + auth: false, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield async *func(this: TestContext) { @@ -261,6 +267,7 @@ describe("buildCommand telemetry integration", () => { const command = buildCommand<{ delay: number }, [], TestContext>({ docs: { brief: "Test" }, + auth: false, parameters: { flags: { delay: { @@ -371,6 +378,7 @@ describe("buildCommand", () => { test("builds a valid command object", () => { const command = buildCommand({ docs: { brief: "Test command" }, + auth: false, parameters: { flags: { json: { kind: "boolean", brief: "JSON output", default: false }, @@ -388,6 +396,7 @@ describe("buildCommand", () => { const command = buildCommand<{ json: boolean }, [], TestContext>({ docs: { brief: "Test" }, + auth: false, parameters: { flags: { json: { kind: "boolean", brief: "JSON output", default: false }, @@ -499,6 +508,7 @@ describe("buildCommand", () => { const command = buildCommand<{ limit: number }, [], TestContext>({ docs: { brief: "Test" }, + auth: false, parameters: { flags: { limit: { @@ -547,6 +557,7 @@ describe("buildCommand", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, parameters: { flags: { verbose: { @@ -603,6 +614,7 @@ describe("buildCommand", () => { try { const command = buildCommand<{ verbose: boolean }, [], TestContext>({ docs: { brief: "Test" }, + auth: false, parameters: { flags: { verbose: { @@ -674,6 +686,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: () => "unused" }, parameters: { flags: { @@ -717,6 +730,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: () => "unused" }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield @@ -756,6 +770,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: () => "unused" }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield @@ -794,6 +809,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: () => "unused" }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield @@ -858,6 +874,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: () => "unused" }, parameters: { flags: { @@ -901,6 +918,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: () => "unused" }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield @@ -942,6 +960,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: () => "unused" }, parameters: { flags: { @@ -996,6 +1015,7 @@ describe("buildCommand return-based output", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: (d: { name: string; role: string }) => `${d.name} (${d.role})`, }, @@ -1024,6 +1044,7 @@ describe("buildCommand return-based output", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: (d: { name: string; role: string }) => `${d.name} (${d.role})`, }, @@ -1053,6 +1074,7 @@ describe("buildCommand return-based output", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: (d: { id: number; name: string; role: string }) => `${d.name}`, }, @@ -1084,6 +1106,7 @@ describe("buildCommand return-based output", () => { const makeCommand = () => buildCommand<{ json: boolean; fields?: string[] }, [], TestContext>({ docs: { brief: "Test" }, + auth: false, output: { human: (d: { value: number }) => `Value: ${d.value}`, }, @@ -1131,6 +1154,7 @@ describe("buildCommand return-based output", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: () => "unused", }, @@ -1188,6 +1212,7 @@ describe("buildCommand return-based output", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: (d: { name: string }) => `Hello, ${d.name}!`, }, @@ -1218,6 +1243,7 @@ describe("buildCommand return-based output", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: (d: Array<{ id: number }>) => d.map(((x) => x.id).join(", ")), }, @@ -1247,6 +1273,7 @@ describe("buildCommand return-based output", () => { const makeCommand = () => buildCommand<{ json: boolean; fields?: string[] }, [], TestContext>({ docs: { brief: "Test" }, + auth: false, output: { human: (d: { org: string }) => `Org: ${d.org}`, }, @@ -1285,6 +1312,7 @@ describe("buildCommand return-based output", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: (d: { error: string }) => `Error: ${d.error}`, }, @@ -1333,6 +1361,7 @@ describe("buildCommand return-based output", () => { TestContext >({ docs: { brief: "Test" }, + auth: false, output: { human: (d: { error: string }) => `Error: ${d.error}`, }, diff --git a/test/lib/db/dsn-cache.test.ts b/test/lib/db/dsn-cache.test.ts index 35aa1d760..2f03cbf48 100644 --- a/test/lib/db/dsn-cache.test.ts +++ b/test/lib/db/dsn-cache.test.ts @@ -312,12 +312,19 @@ describe("getCachedDetection", () => { const before = await getCachedDetection(testProjectDir); expect(before).toBeDefined(); - // Wait a moment and modify the source file - await Bun.sleep(10); + // Modify the source file and explicitly bump its mtime to guarantee a change + // (don't rely on sleep — mtime resolution on Linux CI can be coarser than the delay) + const { utimes } = await import("node:fs/promises"); writeFileSync( join(testProjectDir, "src/app.ts"), 'const DSN = "https://changed@o123.ingest.sentry.io/789";' ); + const futureSrcMtime = new Date(sourceMtimes["src/app.ts"] + 5000); + await utimes( + join(testProjectDir, "src/app.ts"), + futureSrcMtime, + futureSrcMtime + ); // Cache should be invalidated const after = await getCachedDetection(testProjectDir); @@ -373,9 +380,11 @@ describe("getCachedDetection", () => { const before = await getCachedDetection(testProjectDir); expect(before).toBeDefined(); - // Wait and add a new file to change directory mtime - await Bun.sleep(10); + // Add a new file and explicitly bump dir mtime to guarantee a change + const { utimes } = await import("node:fs/promises"); writeFileSync(join(testProjectDir, "new-file.txt"), "test"); + const futureRootMtime = new Date(rootDirMtime + 5000); + await utimes(testProjectDir, futureRootMtime, futureRootMtime); // Cache should be invalidated const after = await getCachedDetection(testProjectDir); @@ -407,12 +416,18 @@ describe("getCachedDetection", () => { const before = await getCachedDetection(testProjectDir); expect(before).toBeDefined(); - // Wait and add a new file to src/ to change its mtime - await Bun.sleep(10); + // Add a new file to src/ and explicitly bump its mtime to guarantee a change + const { utimes } = await import("node:fs/promises"); writeFileSync( join(testProjectDir, "src/new-config.ts"), "export default {}" ); + const futureSrcDirMtime = new Date(srcDirMtime + 5000); + await utimes( + join(testProjectDir, "src"), + futureSrcDirMtime, + futureSrcDirMtime + ); // Cache should be invalidated because src/ mtime changed const after = await getCachedDetection(testProjectDir); diff --git a/test/lib/db/project-root-cache.test.ts b/test/lib/db/project-root-cache.test.ts index 2100fd0e8..6eda12024 100644 --- a/test/lib/db/project-root-cache.test.ts +++ b/test/lib/db/project-root-cache.test.ts @@ -54,9 +54,14 @@ describe("getCachedProjectRoot", () => { const before = await getCachedProjectRoot(testProjectDir); expect(before).toBeDefined(); - // Wait a moment and add a new file to change directory mtime - await Bun.sleep(10); + // Add a new file and explicitly bump dir mtime to guarantee a change + // (don't rely on sleep — mtime resolution on Linux CI can be coarser than the delay) + const { stat: statDir, utimes } = await import("node:fs/promises"); + const dirStats = await statDir(testProjectDir); + const cachedMtime = Math.floor(dirStats.mtimeMs); writeFileSync(join(testProjectDir, "new-file.txt"), "test"); + const futureMtime = new Date(cachedMtime + 5000); + await utimes(testProjectDir, futureMtime, futureMtime); // Cache should be invalidated const after = await getCachedProjectRoot(testProjectDir); diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index 24deee61c..e60a34914 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -48,6 +48,9 @@ function makeOptions(overrides?: Partial): WizardOptions { directory: "/tmp/test", yes: true, dryRun: false, + // Provide org to skip resolveOrgSlug which makes API calls to listOrganizations. + // The wizard tests focus on workflow logic, not org resolution. + org: "test-org", ...overrides, }; } @@ -121,6 +124,22 @@ function setupWorkflowSpy() { return { mockWorkflow }; } +// ── Auth env override ─────────────────────────────────────────────────────── +// Clear the preload SENTRY_AUTH_TOKEN so runWizard's internal isAuthenticated +// check uses the mocked getAuthToken. Without this, the env token passes auth +// and runWizard tries to resolve orgs via real API calls, whose retry timers +// keep the event loop alive and prevent the process from exiting. +let savedAuthToken: string | undefined; +beforeEach(() => { + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; +}); +afterEach(() => { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } +}); + // ── Setup / Teardown ──────────────────────────────────────────────────────── beforeEach(() => { @@ -149,8 +168,14 @@ beforeEach(() => { // git spy — default: pass all checks checkGitStatusSpy = spyOn(git, "checkGitStatus").mockResolvedValue(true); - // dep spies + // dep spies — mock the full auth surface so runWizard sees valid auth + // without hitting real APIs (env token is cleared above). getAuthTokenSpy = spyOn(auth, "getAuthToken").mockReturnValue("fake-token"); + spyOn(auth, "getAuthConfig").mockReturnValue({ + token: "fake-token", + source: "oauth" as const, + }); + spyOn(auth, "isAuthenticated").mockReturnValue(true); formatBannerSpy = spyOn(banner, "formatBanner").mockReturnValue("BANNER"); formatResultSpy = spyOn(fmt, "formatResult").mockImplementation(noop); formatErrorSpy = spyOn(fmt, "formatError").mockImplementation(noop); diff --git a/test/lib/list-command.test.ts b/test/lib/list-command.test.ts index 817dd6380..c1c7ccd8d 100644 --- a/test/lib/list-command.test.ts +++ b/test/lib/list-command.test.ts @@ -14,6 +14,9 @@ import { import type { OrgListConfig } from "../../src/lib/org-list.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as orgListModule from "../../src/lib/org-list.js"; +import { useAuthMock } from "../helpers.js"; + +useAuthMock(); describe("parseCursorFlag", () => { test("passes through 'last' keyword", () => { diff --git a/test/lib/resolve-target.test.ts b/test/lib/resolve-target.test.ts index 809853130..7f296a3c4 100644 --- a/test/lib/resolve-target.test.ts +++ b/test/lib/resolve-target.test.ts @@ -440,7 +440,7 @@ describe("fetchProjectId", () => { // No auth token set — refreshToken() will throw AuthError setOrgRegion("test-org", DEFAULT_SENTRY_URL); - expect(fetchProjectId("test-org", "test-project")).rejects.toThrow( + await expect(fetchProjectId("test-org", "test-project")).rejects.toThrow( AuthError ); }); diff --git a/test/lib/telemetry.test.ts b/test/lib/telemetry.test.ts index da7442fa3..46f36c3c9 100644 --- a/test/lib/telemetry.test.ts +++ b/test/lib/telemetry.test.ts @@ -5,7 +5,15 @@ */ import { Database } from "bun:sqlite"; -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + spyOn, + test, +} from "bun:test"; import { chmodSync, mkdirSync, rmSync } from "node:fs"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as Sentry from "@sentry/node-core/light"; @@ -30,6 +38,23 @@ import { withTracingSpan, } from "../../src/lib/telemetry.js"; +// Snapshot beforeExit listeners before any test calls initSentry(true). +// The ProcessSession integration registers an anonymous handler via setupOnce +// that has no cleanup mechanism. After all tests, we remove any listeners +// that weren't present before to prevent the Bun test runner from hanging. +const preTestListeners = new Set(process.rawListeners("beforeExit")); + +afterAll(() => { + for (const listener of process.rawListeners("beforeExit")) { + if (!preTestListeners.has(listener)) { + process.removeListener( + "beforeExit", + listener as (...args: unknown[]) => void + ); + } + } +}); + describe("initSentry", () => { test("returns client with enabled=false when disabled", () => { const client = initSentry(false);