From 9d185e3ec725a21401855a17a0a4748c4f35107e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 15:56:26 +0200 Subject: [PATCH 01/13] fix(test): replace Bun.sleep with utimes in dsn-cache mtime tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sleeping before a file write to force an mtime change is unreliable on Linux CI — the OS mtime resolution can be coarser than the delay, causing intermittent failures. Use utimes() instead to set the mtime to a guaranteed-different value, making the tests deterministic. Co-Authored-By: Claude Sonnet 4.6 --- test/lib/db/dsn-cache.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/test/lib/db/dsn-cache.test.ts b/test/lib/db/dsn-cache.test.ts index 35aa1d760..3c0ed5b06 100644 --- a/test/lib/db/dsn-cache.test.ts +++ b/test/lib/db/dsn-cache.test.ts @@ -312,12 +312,15 @@ 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 +376,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 +412,14 @@ 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); From 1dbdddb5898a5c32a22e2990feb97d2f60f7f0f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 16:01:37 +0200 Subject: [PATCH 02/13] fix(test): format utimes calls to satisfy biome line-length rule Co-Authored-By: Claude Sonnet 4.6 --- test/lib/db/dsn-cache.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/lib/db/dsn-cache.test.ts b/test/lib/db/dsn-cache.test.ts index 3c0ed5b06..2f03cbf48 100644 --- a/test/lib/db/dsn-cache.test.ts +++ b/test/lib/db/dsn-cache.test.ts @@ -320,7 +320,11 @@ describe("getCachedDetection", () => { '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); + await utimes( + join(testProjectDir, "src/app.ts"), + futureSrcMtime, + futureSrcMtime + ); // Cache should be invalidated const after = await getCachedDetection(testProjectDir); @@ -419,7 +423,11 @@ describe("getCachedDetection", () => { "export default {}" ); const futureSrcDirMtime = new Date(srcDirMtime + 5000); - await utimes(join(testProjectDir, "src"), futureSrcDirMtime, futureSrcDirMtime); + await utimes( + join(testProjectDir, "src"), + futureSrcDirMtime, + futureSrcDirMtime + ); // Cache should be invalidated because src/ mtime changed const after = await getCachedDetection(testProjectDir); From dcca225a634101349624d4926530e65218ae60ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 17:00:41 +0200 Subject: [PATCH 03/13] ci: re-trigger unit tests after stuck run From c4113abe3c4a9def5a228599e9826e295c26d5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 17:15:58 +0200 Subject: [PATCH 04/13] fix(telemetry): always remove beforeExit handler on re-init The handler removal was inside if (client?.getOptions().enabled), so calling initSentry(false) in test afterEach hooks never cleaned up the handler registered by the prior initSentry(true) call. The stale handler kept the event loop alive after all tests finished, causing the bun process to hang indefinitely and the CI Unit Tests step to never complete. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/telemetry.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 027f59e4d..340042a21 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -405,6 +405,17 @@ export function initSentry( }, }); + // Always remove the previous handler on re-init. The removal must happen + // unconditionally — not only when enabled=true — so that calling + // initSentry(false) to disable telemetry (e.g. in test afterEach) actually + // cleans up the handler registered by a prior initSentry(true) call. + // Without this, the stale handler keeps the event loop alive after all tests + // finish, preventing the process from exiting. + 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 +453,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); } From 3047930bcdaf06ef45064f409a324d4398453d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 17:23:14 +0200 Subject: [PATCH 05/13] fix(test): replace Bun.sleep with utimes in project-root-cache mtime test Same flakiness as dsn-cache: 10ms sleep is unreliable on Linux CI with 1-second filesystem mtime resolution. Use utimes() to set an explicit future mtime instead. Co-Authored-By: Claude Sonnet 4.6 --- test/lib/db/project-root-cache.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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); From 3e3cdb75caa619e526f5ba4d5074542572f31eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 16:58:58 +0200 Subject: [PATCH 06/13] feat(auth): enforce auth by default in buildCommand (#611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary All commands now require authentication by default. `buildCommand` checks `isAuthenticated()` before invoking the command function and throws `AuthError("not_authenticated")` if no token exists — this feeds into the existing auto-auth middleware that prompts login and retries. Previously each command had to manually guard auth or rely on an API call failing deep in execution. `sentry init` in particular had no auth check at all and would proceed into the wizard flow before eventually failing. ## Changes Added `auth?: boolean` to `buildCommand` options (defaults `true`). The check runs before the generator, so the auto-auth middleware in `cli.ts` catches it and kicks off the interactive login flow on unauthenticated runs. Commands that intentionally work without a token opt out with `auth: false`: - `auth login`, `auth logout`, `auth refresh`, `auth status` - `help`, `schema` - `cli fix`, `cli setup`, `cli upgrade`, `cli feedback` With this in place, the redundant explicit `isAuthenticated()` checks in `auth whoami` and `auth token` were removed. ## Test Plan - `sentry init` (unauthenticated) → should immediately prompt "Authentication required. Starting login flow..." - `sentry auth login` (unauthenticated) → should work normally - `sentry auth status` (unauthenticated) → should show "not authenticated" cleanly, no login prompt - `sentry auth logout` (unauthenticated) → should return "Not currently authenticated." - `SENTRY_AUTH_TOKEN=xxx sentry init` → no auth prompt, proceeds normally --------- Co-authored-by: Claude Sonnet 4.6 --- src/commands/auth/login.ts | 1 + src/commands/auth/logout.ts | 1 + src/commands/auth/refresh.ts | 1 + src/commands/auth/status.ts | 1 + src/commands/auth/token.ts | 1 - src/commands/auth/whoami.ts | 6 ------ src/commands/cli/feedback.ts | 1 + src/commands/cli/fix.ts | 1 + src/commands/cli/setup.ts | 1 + src/commands/cli/upgrade.ts | 1 + src/commands/help.ts | 1 + src/commands/schema.ts | 1 + src/lib/command.ts | 30 +++++++++++++++++++++++++++++- src/lib/list-command.ts | 1 + 14 files changed, 40 insertions(+), 8 deletions(-) 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 { From 1b360cf6779875f03db686512e031552265f9e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 17:00:41 +0200 Subject: [PATCH 07/13] ci: re-trigger unit tests after stuck run From 8f2e701877a09fd090810d1ff76d1ccbc2c38990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 17:38:45 +0200 Subject: [PATCH 08/13] fix(tests): mock getAuthConfig in log list tests after auth guard (#611) Co-Authored-By: Claude Sonnet 4.6 --- test/commands/log/list.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/commands/log/list.test.ts b/test/commands/log/list.test.ts index 43ecaab2a..ab0f50afd 100644 --- a/test/commands/log/list.test.ts +++ b/test/commands/log/list.test.ts @@ -45,6 +45,8 @@ import * as resolveTarget from "../../../src/lib/resolve-target.js"; 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"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as dbAuth from "../../../src/lib/db/auth.js"; import type { SentryLog, TraceLog } from "../../../src/types/sentry.js"; // ============================================================================ @@ -237,6 +239,23 @@ 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) // ============================================================================ From c2c9655b3262ca541423db200dc99ca5a65b9ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 17:42:03 +0200 Subject: [PATCH 09/13] fix(tests): fix import order in log list tests for biome lint Co-Authored-By: Claude Sonnet 4.6 --- test/commands/log/list.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/commands/log/list.test.ts b/test/commands/log/list.test.ts index ab0f50afd..f6803dca4 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, @@ -45,8 +47,6 @@ import * as resolveTarget from "../../../src/lib/resolve-target.js"; 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"; -// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking -import * as dbAuth from "../../../src/lib/db/auth.js"; import type { SentryLog, TraceLog } from "../../../src/types/sentry.js"; // ============================================================================ From 2b3af8d6c8c67a94c88cf17da4d9e5ce89518140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 18:40:42 +0200 Subject: [PATCH 10/13] fix(telemetry): close previous Sentry client before re-init to remove stale beforeExit listeners LightNodeClient.startClientReportTracking() and enableLogs both register process.on('beforeExit', ...) handlers that are only removed by calling client.close(). Without closing the previous client before Sentry.init(), re-calling initSentry (e.g. initSentry(false) in test afterEach) accumulates stale listeners that fire async work (HTTP sends) on beforeExit, preventing the bun process from exiting after tests complete. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/telemetry.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 340042a21..5121c8130 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -355,6 +355,15 @@ export function initSentry( const libraryMode = options?.libraryMode ?? false; const environment = getEnv().NODE_ENV ?? "development"; + // Close the previous client before re-initializing. LightNodeClient registers + // process.on('beforeExit', ...) listeners for client-report and log flushing + // via startClientReportTracking() and enableLogs. These are only removed by + // calling client.close(). Without this, re-initializing (e.g. initSentry(false) + // in test afterEach) accumulates stale listeners that keep the event loop alive + // after all tests complete, preventing the bun process from exiting. + // close(0) removes the listeners synchronously; we don't need to await the flush. + Sentry.getClient()?.close(0); + const client = Sentry.init({ dsn: SENTRY_CLI_DSN, enabled, From 976b79451e2d897fb943f775106dd62cf454df50 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:42:00 +0530 Subject: [PATCH 11/13] fix(test): adapt tests for buildCommand auth guard (#615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the ~200 test failures caused by the auth guard added in #611. Meant to be merged into #612 so both the CI hang (dsn-cache timing + telemetry beforeExit) and the auth guard breakage ship together. ## Problem `buildCommand`'s new auth guard calls `getAuthConfig()` before every command. Test environments had no auth token set, so all command tests hit `AuthError("not_authenticated")` before the func body ran. ## Changes **Global fix (test/preload.ts):** - Set a fake `SENTRY_AUTH_TOKEN` in the test preload so the auth guard passes for all tests. Real API calls are blocked by the global fetch mock. **Framework tests (test/lib/command.test.ts):** - Add `auth: false` to all 29 test commands — these test flag handling, telemetry, and output rendering, not authentication. **Auth-specific tests (logout, refresh, whoami, project list):** - Tests that verify unauthenticated behavior or `SENTRY_TOKEN` priority now explicitly save/clear/restore `SENTRY_AUTH_TOKEN`. ## Test plan - 215 tests across the 7 most-affected files pass with 0 failures - `bun test test/commands` — 1209 pass - Lint and typecheck pass --------- Co-authored-by: Miguel Betegón Co-authored-by: Claude Sonnet 4.6 --- src/lib/telemetry.ts | 31 +++++++-------- test/commands/auth/logout.test.ts | 6 +++ test/commands/auth/refresh.test.ts | 6 +++ test/commands/auth/whoami.test.ts | 20 ++++++++++ test/commands/project/list.test.ts | 38 ++++++++++++++----- test/lib/api-client.test.ts | 11 ++++++ test/lib/command.test.ts | 29 ++++++++++++++ test/lib/config.test.ts | 17 ++++++++- .../local-ops.create-sentry-project.test.ts | 14 +++++++ test/lib/init/wizard-runner.test.ts | 24 +++++++++++- test/lib/resolve-target.test.ts | 15 ++++++-- test/lib/telemetry.test.ts | 27 ++++++++++++- test/preload.ts | 6 +++ 13 files changed, 210 insertions(+), 34 deletions(-) diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 5121c8130..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,13 +355,11 @@ export function initSentry( const libraryMode = options?.libraryMode ?? false; const environment = getEnv().NODE_ENV ?? "development"; - // Close the previous client before re-initializing. LightNodeClient registers - // process.on('beforeExit', ...) listeners for client-report and log flushing - // via startClientReportTracking() and enableLogs. These are only removed by - // calling client.close(). Without this, re-initializing (e.g. initSentry(false) - // in test afterEach) accumulates stale listeners that keep the event loop alive - // after all tests complete, preventing the bun process from exiting. - // close(0) removes the listeners synchronously; we don't need to await the flush. + // 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({ @@ -383,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, @@ -414,12 +414,7 @@ export function initSentry( }, }); - // Always remove the previous handler on re-init. The removal must happen - // unconditionally — not only when enabled=true — so that calling - // initSentry(false) to disable telemetry (e.g. in test afterEach) actually - // cleans up the handler registered by a prior initSentry(true) call. - // Without this, the stale handler keeps the event loop alive after all tests - // finish, preventing the process from exiting. + // Always remove our own previous handler on re-init. if (currentBeforeExitHandler) { process.removeListener("beforeExit", currentBeforeExitHandler); currentBeforeExitHandler = null; diff --git a/test/commands/auth/logout.test.ts b/test/commands/auth/logout.test.ts index 8953cb448..8bf8282a9 100644 --- a/test/commands/auth/logout.test.ts +++ b/test/commands/auth/logout.test.ts @@ -126,6 +126,9 @@ 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 + const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; process.env.SENTRY_TOKEN = "sntrys_token_456"; const { context } = createContext(); @@ -138,6 +141,9 @@ describe("logoutCommand.func", () => { expect(msg).toContain("SENTRY_TOKEN"); } finally { delete process.env.SENTRY_TOKEN; + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } } expect(clearAuthSpy).not.toHaveBeenCalled(); }); diff --git a/test/commands/auth/refresh.test.ts b/test/commands/auth/refresh.test.ts index 94c786a26..9a744ef4e 100644 --- a/test/commands/auth/refresh.test.ts +++ b/test/commands/auth/refresh.test.ts @@ -85,6 +85,9 @@ 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 + const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; process.env.SENTRY_TOKEN = "sntrys_token_456"; const { context } = createContext(); @@ -99,6 +102,9 @@ describe("refreshCommand.func", () => { expect((err as AuthError).message).not.toContain("SENTRY_AUTH_TOKEN"); } finally { delete process.env.SENTRY_TOKEN; + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } } expect(refreshTokenSpy).not.toHaveBeenCalled(); diff --git a/test/commands/auth/whoami.test.ts b/test/commands/auth/whoami.test.ts index dd44e52dc..0ed717736 100644 --- a/test/commands/auth/whoami.test.ts +++ b/test/commands/auth/whoami.test.ts @@ -84,6 +84,26 @@ describe("whoamiCommand.func", () => { }); describe("unauthenticated", () => { + let getAuthConfigSpy: ReturnType; + let savedAuthToken: string | undefined; + + beforeEach(() => { + // Clear env token and mock getAuthConfig so buildCommand's auth guard + // sees no credentials — this tests the unauthenticated path end-to-end. + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + getAuthConfigSpy = spyOn(dbAuth, "getAuthConfig").mockReturnValue( + undefined + ); + }); + + afterEach(() => { + getAuthConfigSpy.mockRestore(); + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } + }); + test("throws AuthError(not_authenticated) when no token stored", async () => { isAuthenticatedSpy.mockReturnValue(false); diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index 870f45ae9..dbc7b9976 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -969,8 +969,17 @@ describe("fetchOrgProjectsSafe", () => { test("propagates AuthError when not authenticated", async () => { // Clear auth token so the API client throws AuthError before making any request await clearAuth(); - - await expect(fetchOrgProjectsSafe("myorg")).rejects.toThrow(AuthError); + // Also clear env token — preload sets a fake one for the auth guard + const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + + try { + await expect(fetchOrgProjectsSafe("myorg")).rejects.toThrow(AuthError); + } finally { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } + } }); }); @@ -1233,14 +1242,23 @@ describe("handleAutoDetect", () => { setDefaults("test-org"); // Clear auth so getAuthToken() throws AuthError before any fetch await clearAuth(); - - await expect( - handleAutoDetect("/tmp/test-project", { - limit: 30, - json: true, - fresh: false, - }) - ).rejects.toThrow(AuthError); + // Also clear env token — preload sets a fake one for the auth guard + const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + + try { + await expect( + handleAutoDetect("/tmp/test-project", { + limit: 30, + json: true, + fresh: false, + }) + ).rejects.toThrow(AuthError); + } finally { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } + } }); test("slow path: uses full fetch when platform filter is active", async () => { diff --git a/test/lib/api-client.test.ts b/test/lib/api-client.test.ts index 07f66ac51..a4a930120 100644 --- a/test/lib/api-client.test.ts +++ b/test/lib/api-client.test.ts @@ -107,6 +107,17 @@ function createMockFetch( describe("401 retry behavior", () => { // Note: These tests use rawApiRequest which goes to control silo (sentry.io) // and supports 401 retry with token refresh. + // Clear preload SENTRY_AUTH_TOKEN so the DB-stored token is used. + 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; + } + }); test("retries request with new token on 401 response", async () => { const requests: RequestLog[] = []; 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/config.test.ts b/test/lib/config.test.ts index ebd9af4ed..1d4730ae6 100644 --- a/test/lib/config.test.ts +++ b/test/lib/config.test.ts @@ -4,7 +4,7 @@ * Integration tests for SQLite-based config storage. */ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { @@ -47,6 +47,21 @@ import { useTestConfigDir } from "../helpers.js"; */ const getConfigDir = useTestConfigDir("test-config-"); +/** + * Clear the preload SENTRY_AUTH_TOKEN so auth tests exercise the DB path + * (getAuthToken/getAuthConfig check env vars first, which would bypass DB storage). + */ +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; + } +}); + describe("auth token management", () => { test("setAuthToken stores token", async () => { await setAuthToken("test-token-123"); diff --git a/test/lib/init/local-ops.create-sentry-project.test.ts b/test/lib/init/local-ops.create-sentry-project.test.ts index deb034578..6c83fe6dd 100644 --- a/test/lib/init/local-ops.create-sentry-project.test.ts +++ b/test/lib/init/local-ops.create-sentry-project.test.ts @@ -61,6 +61,20 @@ const sampleProject: SentryProject = { dateCreated: "2026-03-04T00:00:00Z", }; +// Clear the preload SENTRY_AUTH_TOKEN so isAuthenticated() / getAuthConfig() +// use the DB path. Without this, the env token causes handleLocalOp to skip +// the "Not authenticated" branch and hit unexpected code paths. +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; + } +}); + describe("create-sentry-project", () => { let resolveOrgSpy: ReturnType; let listOrgsSpy: ReturnType; diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index 24deee61c..efdd450b0 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -121,6 +121,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 +165,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/resolve-target.test.ts b/test/lib/resolve-target.test.ts index 809853130..bdcdcce6c 100644 --- a/test/lib/resolve-target.test.ts +++ b/test/lib/resolve-target.test.ts @@ -438,11 +438,20 @@ describe("fetchProjectId", () => { test("rethrows AuthError when not authenticated", async () => { // No auth token set — refreshToken() will throw AuthError + // Clear preload env token so there's truly no auth + const saved = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; setOrgRegion("test-org", DEFAULT_SENTRY_URL); - expect(fetchProjectId("test-org", "test-project")).rejects.toThrow( - AuthError - ); + try { + await expect(fetchProjectId("test-org", "test-project")).rejects.toThrow( + AuthError + ); + } finally { + if (saved !== undefined) { + process.env.SENTRY_AUTH_TOKEN = saved; + } + } }); test("returns undefined on transient server error", async () => { 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); diff --git a/test/preload.ts b/test/preload.ts index e9aaa9dca..7d77e7d86 100644 --- a/test/preload.ts +++ b/test/preload.ts @@ -93,6 +93,12 @@ delete process.env.SENTRY_HOST; delete process.env.SENTRY_ORG; delete process.env.SENTRY_PROJECT; +// Set a fake auth token so buildCommand's auth guard passes in tests. +// Real API calls are blocked by the global fetch mock below. +// Tests that specifically verify unauthenticated behavior (e.g., auth status) +// mock getAuthConfig to return undefined. +process.env.SENTRY_AUTH_TOKEN = "sntrys_test-token-for-unit-tests_000000"; + // Disable telemetry and background update checks in tests // This prevents Sentry SDK from keeping the process alive and making external calls process.env.SENTRY_CLI_NO_TELEMETRY = "1"; From 9cb088b3295166e83d77b10f48b63844b0df356a Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 1 Apr 2026 00:20:58 +0530 Subject: [PATCH 12/13] fix(test): prevent CI hang from unmocked fetch in log list and wizard tests - log/list.test.ts: Mock resolveOrgProjectFromArg in 'allows --sort newest with --follow' test to prevent unmocked fetch call that triggered retry timeouts - wizard-runner.test.ts: Add default org option to skip resolveOrgSlug which calls listOrganizations API --- test/commands/log/list.test.ts | 10 +++++++++- test/lib/init/wizard-runner.test.ts | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/test/commands/log/list.test.ts b/test/commands/log/list.test.ts index f6803dca4..5a9774f0a 100644 --- a/test/commands/log/list.test.ts +++ b/test/commands/log/list.test.ts @@ -856,7 +856,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( @@ -866,6 +873,7 @@ describe("listCommand.func — flag validation", () => { "my-org/my-project" ) ).rejects.not.toThrow(ValidationError); + resolveOrgProjectSpy.mockRestore(); }); }); diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index efdd450b0..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, }; } From b5fb10a2b919d2e65a3df848366be7dee1507a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 22:52:13 +0200 Subject: [PATCH 13/13] refactor(test): replace global SENTRY_AUTH_TOKEN with useAuthMock helper Remove the SENTRY_AUTH_TOKEN env var from test/preload.ts and replace it with explicit getAuthConfig mocks via a new useAuthMock() helper in test/helpers.ts. This is cleaner than the env var approach because: - Mocks are visible in each test file (no hidden global state) - Tests that need unauthenticated behavior work naturally - No env var save/delete/restore boilerplate needed in auth tests - Doesn't change the auth code path (env token vs DB token) Co-Authored-By: Claude Opus 4.6 (1M context) --- test/commands/auth/logout.test.ts | 4 -- test/commands/auth/refresh.test.ts | 4 -- test/commands/auth/whoami.test.ts | 24 ++++-------- test/commands/cli.test.ts | 3 ++ test/commands/dashboard/create.test.ts | 3 ++ test/commands/dashboard/list.test.ts | 3 ++ test/commands/dashboard/widget/add.test.ts | 3 ++ test/commands/dashboard/widget/delete.test.ts | 3 ++ test/commands/dashboard/widget/edit.test.ts | 3 ++ test/commands/event/view.test.ts | 3 ++ test/commands/init.test.ts | 3 ++ test/commands/log/list.test.ts | 3 ++ test/commands/log/view.func.test.ts | 3 ++ test/commands/log/view.test.ts | 3 ++ test/commands/project/create.test.ts | 3 ++ test/commands/project/delete.test.ts | 3 ++ test/commands/project/list.test.ts | 38 +++++-------------- test/commands/project/view.func.test.ts | 3 ++ test/commands/repo/list.test.ts | 3 ++ test/commands/span/list.test.ts | 3 ++ test/commands/span/view.test.ts | 3 ++ test/commands/team/list.test.ts | 3 ++ test/commands/trace/list.test.ts | 3 ++ test/commands/trace/logs.test.ts | 3 ++ test/commands/trace/view.func.test.ts | 3 ++ test/commands/trial/list.test.ts | 3 ++ test/commands/trial/start.test.ts | 3 ++ test/helpers.ts | 32 +++++++++++++++- test/lib/api-client.test.ts | 11 ------ test/lib/config.test.ts | 17 +-------- .../local-ops.create-sentry-project.test.ts | 14 ------- test/lib/list-command.test.ts | 3 ++ test/lib/resolve-target.test.ts | 15 ++------ test/preload.ts | 6 --- 34 files changed, 125 insertions(+), 112 deletions(-) diff --git a/test/commands/auth/logout.test.ts b/test/commands/auth/logout.test.ts index 8bf8282a9..77d0c60aa 100644 --- a/test/commands/auth/logout.test.ts +++ b/test/commands/auth/logout.test.ts @@ -127,7 +127,6 @@ describe("logoutCommand.func", () => { isEnvTokenActiveSpy.mockReturnValue(true); // Set env var directly — getActiveEnvVarName() reads env vars via getEnvToken() // Clear SENTRY_AUTH_TOKEN so SENTRY_TOKEN takes priority - const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; delete process.env.SENTRY_AUTH_TOKEN; process.env.SENTRY_TOKEN = "sntrys_token_456"; const { context } = createContext(); @@ -141,9 +140,6 @@ describe("logoutCommand.func", () => { expect(msg).toContain("SENTRY_TOKEN"); } finally { delete process.env.SENTRY_TOKEN; - if (savedAuthToken !== undefined) { - process.env.SENTRY_AUTH_TOKEN = savedAuthToken; - } } expect(clearAuthSpy).not.toHaveBeenCalled(); }); diff --git a/test/commands/auth/refresh.test.ts b/test/commands/auth/refresh.test.ts index 9a744ef4e..189ebcc2e 100644 --- a/test/commands/auth/refresh.test.ts +++ b/test/commands/auth/refresh.test.ts @@ -86,7 +86,6 @@ describe("refreshCommand.func", () => { isEnvTokenActiveSpy.mockReturnValue(true); // Set env var directly — getActiveEnvVarName() reads env vars via getEnvToken() // Clear SENTRY_AUTH_TOKEN so SENTRY_TOKEN takes priority - const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; delete process.env.SENTRY_AUTH_TOKEN; process.env.SENTRY_TOKEN = "sntrys_token_456"; @@ -102,9 +101,6 @@ describe("refreshCommand.func", () => { expect((err as AuthError).message).not.toContain("SENTRY_AUTH_TOKEN"); } finally { delete process.env.SENTRY_TOKEN; - if (savedAuthToken !== undefined) { - process.env.SENTRY_AUTH_TOKEN = savedAuthToken; - } } expect(refreshTokenSpy).not.toHaveBeenCalled(); diff --git a/test/commands/auth/whoami.test.ts b/test/commands/auth/whoami.test.ts index 0ed717736..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,29 +84,16 @@ describe("whoamiCommand.func", () => { afterEach(() => { isAuthenticatedSpy.mockRestore(); + getAuthConfigSpy.mockRestore(); getCurrentUserSpy.mockRestore(); setUserInfoSpy.mockRestore(); }); describe("unauthenticated", () => { - let getAuthConfigSpy: ReturnType; - let savedAuthToken: string | undefined; - beforeEach(() => { - // Clear env token and mock getAuthConfig so buildCommand's auth guard + // Override the auth mock so buildCommand's auth guard // sees no credentials — this tests the unauthenticated path end-to-end. - savedAuthToken = process.env.SENTRY_AUTH_TOKEN; - delete process.env.SENTRY_AUTH_TOKEN; - getAuthConfigSpy = spyOn(dbAuth, "getAuthConfig").mockReturnValue( - undefined - ); - }); - - afterEach(() => { - getAuthConfigSpy.mockRestore(); - if (savedAuthToken !== undefined) { - process.env.SENTRY_AUTH_TOKEN = savedAuthToken; - } + getAuthConfigSpy.mockReturnValue(undefined); }); test("throws AuthError(not_authenticated) when no token stored", async () => { 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 5a9774f0a..8beedb87a 100644 --- a/test/commands/log/list.test.ts +++ b/test/commands/log/list.test.ts @@ -48,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 @@ -260,6 +261,8 @@ afterEach(() => { // Standard mode (project-scoped, no trace-id positional) // ============================================================================ +useAuthMock(); + describe("listCommand.func — standard mode", () => { let listLogsSpy: ReturnType; let resolveOrgProjectSpy: ReturnType; 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/list.test.ts b/test/commands/project/list.test.ts index dbc7b9976..870f45ae9 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -969,17 +969,8 @@ describe("fetchOrgProjectsSafe", () => { test("propagates AuthError when not authenticated", async () => { // Clear auth token so the API client throws AuthError before making any request await clearAuth(); - // Also clear env token — preload sets a fake one for the auth guard - const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; - delete process.env.SENTRY_AUTH_TOKEN; - - try { - await expect(fetchOrgProjectsSafe("myorg")).rejects.toThrow(AuthError); - } finally { - if (savedAuthToken !== undefined) { - process.env.SENTRY_AUTH_TOKEN = savedAuthToken; - } - } + + await expect(fetchOrgProjectsSafe("myorg")).rejects.toThrow(AuthError); }); }); @@ -1242,23 +1233,14 @@ describe("handleAutoDetect", () => { setDefaults("test-org"); // Clear auth so getAuthToken() throws AuthError before any fetch await clearAuth(); - // Also clear env token — preload sets a fake one for the auth guard - const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; - delete process.env.SENTRY_AUTH_TOKEN; - - try { - await expect( - handleAutoDetect("/tmp/test-project", { - limit: 30, - json: true, - fresh: false, - }) - ).rejects.toThrow(AuthError); - } finally { - if (savedAuthToken !== undefined) { - process.env.SENTRY_AUTH_TOKEN = savedAuthToken; - } - } + + await expect( + handleAutoDetect("/tmp/test-project", { + limit: 30, + json: true, + fresh: false, + }) + ).rejects.toThrow(AuthError); }); test("slow path: uses full fetch when platform filter is active", async () => { 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/api-client.test.ts b/test/lib/api-client.test.ts index a4a930120..07f66ac51 100644 --- a/test/lib/api-client.test.ts +++ b/test/lib/api-client.test.ts @@ -107,17 +107,6 @@ function createMockFetch( describe("401 retry behavior", () => { // Note: These tests use rawApiRequest which goes to control silo (sentry.io) // and supports 401 retry with token refresh. - // Clear preload SENTRY_AUTH_TOKEN so the DB-stored token is used. - 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; - } - }); test("retries request with new token on 401 response", async () => { const requests: RequestLog[] = []; diff --git a/test/lib/config.test.ts b/test/lib/config.test.ts index 1d4730ae6..ebd9af4ed 100644 --- a/test/lib/config.test.ts +++ b/test/lib/config.test.ts @@ -4,7 +4,7 @@ * Integration tests for SQLite-based config storage. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { @@ -47,21 +47,6 @@ import { useTestConfigDir } from "../helpers.js"; */ const getConfigDir = useTestConfigDir("test-config-"); -/** - * Clear the preload SENTRY_AUTH_TOKEN so auth tests exercise the DB path - * (getAuthToken/getAuthConfig check env vars first, which would bypass DB storage). - */ -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; - } -}); - describe("auth token management", () => { test("setAuthToken stores token", async () => { await setAuthToken("test-token-123"); diff --git a/test/lib/init/local-ops.create-sentry-project.test.ts b/test/lib/init/local-ops.create-sentry-project.test.ts index 6c83fe6dd..deb034578 100644 --- a/test/lib/init/local-ops.create-sentry-project.test.ts +++ b/test/lib/init/local-ops.create-sentry-project.test.ts @@ -61,20 +61,6 @@ const sampleProject: SentryProject = { dateCreated: "2026-03-04T00:00:00Z", }; -// Clear the preload SENTRY_AUTH_TOKEN so isAuthenticated() / getAuthConfig() -// use the DB path. Without this, the env token causes handleLocalOp to skip -// the "Not authenticated" branch and hit unexpected code paths. -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; - } -}); - describe("create-sentry-project", () => { let resolveOrgSpy: ReturnType; let listOrgsSpy: ReturnType; 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 bdcdcce6c..7f296a3c4 100644 --- a/test/lib/resolve-target.test.ts +++ b/test/lib/resolve-target.test.ts @@ -438,20 +438,11 @@ describe("fetchProjectId", () => { test("rethrows AuthError when not authenticated", async () => { // No auth token set — refreshToken() will throw AuthError - // Clear preload env token so there's truly no auth - const saved = process.env.SENTRY_AUTH_TOKEN; - delete process.env.SENTRY_AUTH_TOKEN; setOrgRegion("test-org", DEFAULT_SENTRY_URL); - try { - await expect(fetchProjectId("test-org", "test-project")).rejects.toThrow( - AuthError - ); - } finally { - if (saved !== undefined) { - process.env.SENTRY_AUTH_TOKEN = saved; - } - } + await expect(fetchProjectId("test-org", "test-project")).rejects.toThrow( + AuthError + ); }); test("returns undefined on transient server error", async () => { diff --git a/test/preload.ts b/test/preload.ts index 7d77e7d86..e9aaa9dca 100644 --- a/test/preload.ts +++ b/test/preload.ts @@ -93,12 +93,6 @@ delete process.env.SENTRY_HOST; delete process.env.SENTRY_ORG; delete process.env.SENTRY_PROJECT; -// Set a fake auth token so buildCommand's auth guard passes in tests. -// Real API calls are blocked by the global fetch mock below. -// Tests that specifically verify unauthenticated behavior (e.g., auth status) -// mock getAuthConfig to return undefined. -process.env.SENTRY_AUTH_TOKEN = "sntrys_test-token-for-unit-tests_000000"; - // Disable telemetry and background update checks in tests // This prevents Sentry SDK from keeping the process alive and making external calls process.env.SENTRY_CLI_NO_TELEMETRY = "1";