diff --git a/apps/cli-docs/src/content/docs/library-usage.md b/apps/cli-docs/src/content/docs/library-usage.md index c72bba3cd..44493bce7 100644 --- a/apps/cli-docs/src/content/docs/library-usage.md +++ b/apps/cli-docs/src/content/docs/library-usage.md @@ -194,16 +194,22 @@ try { ## Environment Isolation -The library never mutates `process.env`. Each invocation creates an isolated -copy of the environment. This means: +The library never mutates `process.env`. Each invocation captures the environment +when called and owns its in-memory auth and routing state. Pending requests keep that +context even if the command fails before they finish. This means: - Your application's env vars are never touched - Multiple sequential calls are safe - Auth tokens passed via `token` don't leak to subsequent calls +Stored login credentials remain shared by calls using the same config directory. +Each HTTP response is cached under the identity selected for that request, even if +the stored session changes while it is in flight. + :::note Concurrent calls are not supported in the current version. -Calls should be sequential (awaited one at a time). +Overlapping invocations reject with `SentryError`. Calls must be sequential +(awaited one at a time), including calls on different SDK instances. ::: ## Comparison with Subprocess @@ -244,7 +250,7 @@ for await (const snapshot of sdk.run("dashboard", "view", "123", "--refresh", "3 // Stop streaming by breaking out of the loop for await (const log of sdk.log.list({ follow: "2" })) { - if (someCondition) break; // Streaming stops immediately + if (someCondition) break; // Signals cancellation and waits for cleanup } ``` @@ -263,10 +269,12 @@ setTimeout(() => controller.abort(), 30_000); for await (const log of sdk.log.list({ follow: "5" })) { console.log(log); } -// Loop exits when signal fires +// Loop exits after cancellation and cleanup finish ``` :::note Concurrent streaming calls are not supported. Each streaming invocation -uses an isolated environment — only one can be active at a time. +uses an isolated environment — only one SDK invocation can be active at a time. +Finishing iteration or exiting with `break` waits for the producer to stop and +finish cleanup. Await that cleanup before starting another SDK call. ::: diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index edf3c1891..4237d5bbf 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -292,9 +292,10 @@ export const viewCommand = buildCommand({ // Library mode: honor external abort signal (e.g., consumer break) const externalSignal = (this.process as { abortSignal?: AbortSignal }) ?.abortSignal; - if (externalSignal) { - externalSignal.addEventListener("abort", stop, { once: true }); + if (externalSignal?.aborted) { + stop(); } + externalSignal?.addEventListener("abort", stop, { once: true }); let isFirstRender = true; @@ -326,6 +327,7 @@ export const viewCommand = buildCommand({ } } finally { process.removeListener("SIGINT", stop); + externalSignal?.removeEventListener("abort", stop); } return; } diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 4b9fa4a65..419857d3f 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -382,11 +382,15 @@ async function* generateFollowLogs( process.once("SIGINT", stop); // Library mode: honor external abort signal (e.g., consumer break) - if (config.abortSignal) { - config.abortSignal.addEventListener("abort", stop, { once: true }); + if (config.abortSignal?.aborted) { + stop(); } + config.abortSignal?.addEventListener("abort", stop, { once: true }); try { + if (controller.signal.aborted) { + return; + } // Initial fetch const initialLogs = await config.fetch("1m"); if (initialLogs.length > 0) { @@ -410,6 +414,7 @@ async function* generateFollowLogs( } } finally { process.removeListener("SIGINT", stop); + config.abortSignal?.removeEventListener("abort", stop); } } diff --git a/packages/cli/src/lib/api/issues.ts b/packages/cli/src/lib/api/issues.ts index 06f3726b3..0243d9c38 100644 --- a/packages/cli/src/lib/api/issues.ts +++ b/packages/cli/src/lib/api/issues.ts @@ -16,7 +16,9 @@ import { warnIfSaasWithEnvCa, } from "../custom-ca.js"; import { applyCustomHeaders } from "../custom-headers.js"; +import { getIdentityFingerprint } from "../db/auth.js"; import { ApiError, ValidationError } from "../errors.js"; +import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; import { invalidateCachedResponsesMatching } from "../response-cache.js"; import { getApiBaseUrl } from "../sentry-client.js"; @@ -664,13 +666,22 @@ export async function mergeIssues( // stale data. const apiBase = getApiBaseUrl().replace(TRAILING_SLASH_RE, ""); const affectedIds = data.merge.children.toSpliced(0, 0, data.merge.parent); - await Promise.all( - affectedIds.map((id) => - invalidateCachedResponsesMatching( - `${apiBase}/api/0/issues/${encodeURIComponent(id)}/`, + try { + const identity = getIdentityFingerprint(); + await Promise.all( + affectedIds.map((id) => + invalidateCachedResponsesMatching( + `${apiBase}/api/0/issues/${encodeURIComponent(id)}/`, + identity, + ), ), - ), - ); + ); + } catch (error) { + // Cache maintenance must not turn a completed merge into a failure. + logger + .withTag("issues") + .debug("Merged issue cache invalidation failed", error); + } return data.merge; } catch (error) { // The bulk-mutate endpoint returns 204 when no matching issues are diff --git a/packages/cli/src/lib/async-channel.ts b/packages/cli/src/lib/async-channel.ts index 11cca4f19..ef80fef6f 100644 --- a/packages/cli/src/lib/async-channel.ts +++ b/packages/cli/src/lib/async-channel.ts @@ -15,9 +15,9 @@ export type AsyncChannelOptions = { /** * Called when the consumer calls `return()` on the iterator * (e.g., `break` in a `for await...of` loop). Use this to signal - * the producer to stop. + * the producer to stop. Iterator return waits for asynchronous cleanup. */ - onReturn?: () => void; + onReturn?: () => void | Promise; }; /** @@ -111,16 +111,19 @@ export function createAsyncChannel( const iterator: AsyncIterator = { next, - return(): Promise> { + async return(): Promise> { closed = true; buffer.length = 0; - if (pending) { - const p = pending; - pending = undefined; - p.resolve({ value: undefined as T, done: true }); + try { + await options?.onReturn?.(); + } finally { + if (pending) { + const p = pending; + pending = undefined; + p.resolve({ value: undefined as T, done: true }); + } } - options?.onReturn?.(); - return Promise.resolve({ value: undefined as T, done: true }); + return { value: undefined as T, done: true }; }, }; diff --git a/packages/cli/src/lib/custom-headers.ts b/packages/cli/src/lib/custom-headers.ts index 8cca5b9e2..aeb911327 100644 --- a/packages/cli/src/lib/custom-headers.ts +++ b/packages/cli/src/lib/custom-headers.ts @@ -10,7 +10,7 @@ * * The library API (`createSentrySDK({ headers })`) bypasses the string format * and sets the structured headers for the current invocation via - * {@link setCustomHeadersOverride}, validated with the same rules. + * {@link withCustomHeadersOverride}, validated with the same rules. * * @example * ```bash @@ -28,7 +28,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { getConfiguredSentryUrl } from "./constants.js"; import { getDefaultHeaders } from "./db/defaults.js"; -import { getEnv } from "./env.js"; +import { createInvocationState, getEnv } from "./env.js"; import { ConfigError } from "./errors.js"; import { logger } from "./logger.js"; import { isSentrySaasUrl } from "./sentry-urls.js"; @@ -66,23 +66,14 @@ const HEADER_SEPARATOR_RE = /[;\n]/; /** Strips trailing carriage return from a line (Windows line endings). */ const TRAILING_CR_RE = /\r$/; -/** Cached parsed headers (from env var or defaults). `undefined` = not yet parsed. */ -let cachedHeaders: readonly [string, string][] | undefined; - -/** Tracks the raw source string that produced `cachedHeaders`, for invalidation. */ -let cachedRawSource: string | undefined; - -/** Whether the SaaS warning has already been logged this session. */ -let saasWarningLogged = false; - -/** Whether the untrusted-destination warning has already been logged. */ -let untrustedDestinationWarningLogged = false; - -/** - * Structured headers set by the library API for the current invocation. - * `undefined` = not set, fall through to the env var / SQLite defaults. - */ -let overrideHeaders: readonly [string, string][] | undefined; +type HeaderState = { + cachedHeaders?: readonly [string, string][]; + cachedRawSource?: string; + saasWarningLogged?: boolean; + untrustedDestinationWarningLogged?: boolean; + overrideHeaders?: readonly [string, string][]; +}; +const getHeaderState = createInvocationState(() => ({})); const scopedHeadersOverride = new AsyncLocalStorage<{ value: readonly [string, string][] | undefined; }>(); @@ -162,7 +153,8 @@ export function parseCustomHeaders(raw: string): readonly [string, string][] { * over an inherited env var. The self-hosted guard and the request-origin * trust check in {@link applyCustomHeaders} still apply. * - * Pass `undefined` to clear. The SDK invoke layer calls this next to `setEnv`. + * Pass `undefined` to inherit env/SQLite headers. The SDK validates overrides + * inside its invocation context. * * @param headers - Header name/value map from `SentryOptions.headers` * @throws {ConfigError} On invalid or reserved header names @@ -191,7 +183,7 @@ function validateCustomHeadersOverride( export function setCustomHeadersOverride( headers: Record | undefined, ): void { - overrideHeaders = validateCustomHeadersOverride(headers); + getHeaderState().overrideHeaders = validateCustomHeadersOverride(headers); } export function withCustomHeadersOverride( @@ -240,11 +232,12 @@ function resolveRawHeaders(): string | undefined { /** Self-hosted guard: warn once and report false on SaaS. */ function passesSelfHostedGuard(): boolean { + const state = getHeaderState(); if (isSelfHosted()) { return true; } - if (!saasWarningLogged) { - saasWarningLogged = true; + if (!state.saasWarningLogged) { + state.saasWarningLogged = true; log.warn( "Custom headers are set but no self-hosted Sentry instance is configured. Headers will be ignored.", ); @@ -263,8 +256,9 @@ function passesSelfHostedGuard(): boolean { * because `SENTRY_HOST` can be set dynamically by URL argument parsing. */ export function getCustomHeaders(): readonly [string, string][] { + const state = getHeaderState(); const scoped = scopedHeadersOverride.getStore(); - const effective = scoped ? scoped.value : overrideHeaders; + const effective = scoped ? scoped.value : state.overrideHeaders; if (effective !== undefined) { return effective.length > 0 && passesSelfHostedGuard() ? effective : []; } @@ -279,13 +273,13 @@ export function getCustomHeaders(): readonly [string, string][] { } // Return cached result if the raw source hasn't changed - if (cachedHeaders !== undefined && cachedRawSource === raw) { - return cachedHeaders; + if (state.cachedHeaders !== undefined && state.cachedRawSource === raw) { + return state.cachedHeaders; } - cachedHeaders = parseCustomHeaders(raw); - cachedRawSource = raw; - return cachedHeaders; + state.cachedHeaders = parseCustomHeaders(raw); + state.cachedRawSource = raw; + return state.cachedHeaders; } /** @@ -308,14 +302,15 @@ export function applyCustomHeaders( requestUrl: string | URL | Request, isTrusted = isRequestOriginTrustedForCustomHeaders(requestUrl), ): void { + const state = getHeaderState(); const customHeaders = getCustomHeaders(); if (customHeaders.length === 0) { return; } if (!isTrusted) { - if (!untrustedDestinationWarningLogged) { - untrustedDestinationWarningLogged = true; + if (!state.untrustedDestinationWarningLogged) { + state.untrustedDestinationWarningLogged = true; log.warn( "Skipping custom headers for request to untrusted host. " + "If this is legitimate, run 'sentry auth login --url ' against the intended instance.", @@ -334,9 +329,10 @@ export function applyCustomHeaders( * @internal */ export function _resetCustomHeadersCache(): void { - cachedHeaders = undefined; - cachedRawSource = undefined; - overrideHeaders = undefined; - saasWarningLogged = false; - untrustedDestinationWarningLogged = false; + const state = getHeaderState(); + state.cachedHeaders = undefined; + state.cachedRawSource = undefined; + state.overrideHeaders = undefined; + state.saasWarningLogged = false; + state.untrustedDestinationWarningLogged = false; } diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 65af35268..70ebf18eb 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -3,9 +3,10 @@ */ import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; import { normalizeAuthToken, trimAuthToken } from "../auth-header.js"; import { DEFAULT_SENTRY_URL, getConfiguredSentryUrl } from "../constants.js"; -import { getEnv } from "../env.js"; +import { createInvocationState, getEnv } from "../env.js"; import { getBootConfiguredSentryUrl, getEnvTokenHost, @@ -14,7 +15,7 @@ import { ConfigError } from "../errors.js"; import { logger } from "../logger.js"; import { normalizeHttpOrigin } from "../sentry-urls.js"; import { withDbSpan } from "../telemetry.js"; -import { getDatabase } from "./index.js"; +import { getDatabase, getDbPath } from "./index.js"; import { clearAllIssueOrgCache } from "./issue-org-cache.js"; import { clearTrustedHostState } from "./regions.js"; import { runUpsert } from "./utils.js"; @@ -300,10 +301,13 @@ export function getUsableStoredTokenHost(): string | undefined { } } -const authCacheState = { - tokens: new WeakMap(), - fingerprints: new WeakMap(), +/** Wrappers distinguish cache misses from cached absence of credentials. */ +type AuthState = { + token?: { value: string | undefined }; + hasStoredCreds?: { value: boolean }; + fingerprint?: string; }; +const getAuthState = createInvocationState(() => ({})); /** * Get the active auth token. @@ -312,13 +316,12 @@ const authCacheState = { * With `SENTRY_FORCE_ENV_TOKEN=1`: checks env vars first (old behavior). */ export function getAuthToken(): string | undefined { - const env = getEnv(); - const cached = authCacheState.tokens.get(env); - if (cached !== undefined) { - return cached.value; + const state = getAuthState(); + if (state.token !== undefined) { + return state.token.value; } const value = computeAuthToken(); - authCacheState.tokens.set(env, { value }); + state.token = { value }; return value; } @@ -356,34 +359,21 @@ function computeAuthToken(): string | undefined { return; } -/** Reset the memoized auth token. Tests only — call between auth-state mutations. */ +/** Discard the token after auth mutations. */ export function resetAuthTokenCache(): void { - authCacheState.tokens = new WeakMap(); + getAuthState().token = undefined; } -/** Memoized result for {@link hasStoredAuthCredentials}. */ -let cachedHasStoredCreds: { value: boolean } | undefined; - -/** Memoized full auth row for {@link refreshToken}. */ -let cachedAuthRow: { value: AuthRow | undefined } | undefined; - -function getCachedAuthRow(): AuthRow | undefined { - if (cachedAuthRow !== undefined) { - return cachedAuthRow.value; - } - const row = getAuthRow(); - cachedAuthRow = { value: row }; - return row; -} - -/** Reset the memoized auth row. Tests only — call between auth-state mutations. */ -export function resetAuthRowCache(): void { - cachedAuthRow = undefined; +/** Discard the credentials flag after mutations. */ +export function resetHasStoredCredsCache(): void { + getAuthState().hasStoredCreds = undefined; } -/** Reset the memoized stored-credentials flag. Tests only — call between auth-state mutations. */ -export function resetHasStoredCredsCache(): void { - cachedHasStoredCreds = undefined; +/** Discard all credential-derived caches in the current invocation. */ +function resetAuthCaches(): void { + resetIdentityFingerprintCache(); + resetAuthTokenCache(); + resetHasStoredCredsCache(); } /** @@ -445,13 +435,8 @@ export function setAuthToken( ["id"], ); }); - // Auth row changed — drop memoized fingerprint, token, row, and - // stored-credentials flag so the next read reflects the new row. - resetIdentityFingerprintCache(); - refreshIdentityAliases.clear(); - resetAuthTokenCache(); - resetAuthRowCache(); - resetHasStoredCredsCache(); + resetAuthCaches(); + clearRefreshIdentityAliases(); } export async function clearAuth(): Promise { @@ -466,11 +451,8 @@ export async function clearAuth(): Promise { db.query("DELETE FROM pagination_cursors").run(); clearAllIssueOrgCache(); }); - resetIdentityFingerprintCache(); - refreshIdentityAliases.clear(); - resetAuthTokenCache(); - resetAuthRowCache(); - resetHasStoredCredsCache(); + resetAuthCaches(); + clearRefreshIdentityAliases(); // Evict in-process trust extensions tied to the now-cleared identity. clearTrustedHostState(); @@ -508,19 +490,16 @@ export const ANON_IDENTITY = ""; * {@link resetIdentityFingerprintCache}. */ export function getIdentityFingerprint(): string { - const env = getEnv(); - const cached = authCacheState.fingerprints.get(env); - if (cached !== undefined) { - return cached; + const state = getAuthState(); + if (state.fingerprint === undefined) { + state.fingerprint = computeIdentityFingerprint(); } - const fingerprint = computeIdentityFingerprint(); - authCacheState.fingerprints.set(env, fingerprint); - return fingerprint; + return state.fingerprint; } -/** Reset the memoized fingerprint. Tests only — call between auth-state mutations. */ +/** Discard the identity after auth mutations. */ export function resetIdentityFingerprintCache(): void { - authCacheState.fingerprints = new WeakMap(); + getAuthState().fingerprint = undefined; } function computeIdentityFingerprint(): string { @@ -643,7 +622,7 @@ export function getActiveAuthHost(): string | undefined { * - A non-expired token, or * - An expired token with a refresh token (will be refreshed on next use) * - * Memoized within the process. Reset on {@link setAuthToken} and + * Memoized within the current invocation. Reset on {@link setAuthToken} and * {@link clearAuth} mutations. Tests call {@link resetHasStoredCredsCache} * between cases. * @@ -651,8 +630,9 @@ export function getActiveAuthHost(): string | undefined { * when an env token is present. */ export function hasStoredAuthCredentials(): boolean { - if (cachedHasStoredCreds !== undefined) { - return cachedHasStoredCreds.value; + const state = getAuthState(); + if (state.hasStoredCreds !== undefined) { + return state.hasStoredCreds.value; } const row = getAuthRow(); let result = false; @@ -665,7 +645,7 @@ export function hasStoredAuthCredentials(): boolean { result = !!row.refresh_token; } } - cachedHasStoredCreds = { value: result }; + state.hasStoredCreds = { value: result }; return result; } @@ -698,10 +678,24 @@ type StoredCredentialSnapshot = { const refreshPromises = new Map>(); // Only successful rotations can link a pinned in-flight request to the next -// refresh-token identity. A new login or logout clears every link. +// refresh-token identity. A new login or logout clears that store's links. const refreshIdentityAliases = new Map(); const MAX_REFRESH_IDENTITY_ALIASES = 128; +function getAuthStorePath(): string { + getDatabase(); + return realpathSync(getDbPath()); +} + +function clearRefreshIdentityAliases(): void { + const prefix = `${getAuthStorePath()}\0`; + for (const key of refreshIdentityAliases.keys()) { + if (key.startsWith(prefix)) { + refreshIdentityAliases.delete(key); + } + } +} + function rememberRefreshIdentity( host: string, previous: string, @@ -710,7 +704,10 @@ function rememberRefreshIdentity( if (previous === next) { return; } - refreshIdentityAliases.set(`${host}\0${previous}`, next); + refreshIdentityAliases.set( + `${getAuthStorePath()}\0${host}\0${previous}`, + next, + ); if (refreshIdentityAliases.size > MAX_REFRESH_IDENTITY_ALIASES) { const oldest = refreshIdentityAliases.keys().next().value; if (oldest !== undefined) { @@ -725,7 +722,8 @@ function matchesRefreshIdentity( current: string, ): boolean { const seen = new Set(); - const start = `${host}\0${previous}`; + const store = getAuthStorePath(); + const start = `${store}\0${host}\0${previous}`; for (let key = start; !seen.has(key);) { seen.add(key); const next = refreshIdentityAliases.get(key); @@ -735,7 +733,7 @@ function matchesRefreshIdentity( if (next === current) { return true; } - key = `${host}\0${next}`; + key = `${store}\0${host}\0${next}`; } return false; } @@ -801,10 +799,7 @@ function persistRefreshedCredential( })(); }); if (saved) { - resetIdentityFingerprintCache(); - resetAuthTokenCache(); - resetAuthRowCache(); - resetHasStoredCredsCache(); + resetAuthCaches(); } return saved; } @@ -881,19 +876,19 @@ function getEnvRefreshResult( async function refreshStoredCredential( credential: StoredCredentialSnapshot, ): Promise { - const key = `${credential.identity}\0${credential.host}\0${hashIdentity("oauth-access", credential.token)}\0${credential.updatedAt}`; - const existing = refreshPromises.get(key); - if (existing) { - return await existing; + const key = `${getAuthStorePath()}\0${credential.identity}\0${credential.host}\0${hashIdentity("oauth-access", credential.token)}\0${credential.updatedAt}`; + let pending = refreshPromises.get(key); + if (!pending) { + pending = performTokenRefresh(credential).finally(() => { + refreshPromises.delete(key); + }); + refreshPromises.set(key, pending); } - const pending = performTokenRefresh(credential); - refreshPromises.set(key, pending); try { return await pending; } finally { - if (refreshPromises.get(key) === pending) { - refreshPromises.delete(key); - } + // The refresh runs in its creator's context; joiners need fresh auth too. + resetAuthCaches(); } } @@ -912,7 +907,7 @@ export async function refreshToken( const { force = false } = options; const { AuthError } = await import("../errors.js"); - const row = getCachedAuthRow(); + const row = getAuthRow(); if (!row?.token) { // No stored token — try env token as fallback diff --git a/packages/cli/src/lib/db/project-root-cache.ts b/packages/cli/src/lib/db/project-root-cache.ts index af30afb4d..c14e17c58 100644 --- a/packages/cli/src/lib/db/project-root-cache.ts +++ b/packages/cli/src/lib/db/project-root-cache.ts @@ -80,13 +80,17 @@ export async function getCachedProjectRoot( if (currentMtime !== row.cwd_mtime) { // Directory structure changed, invalidate cache - db.query("DELETE FROM project_root_cache WHERE cwd = ?").run(cwd); + getDatabase() + .query("DELETE FROM project_root_cache WHERE cwd = ?") + .run(cwd); recordCacheHit("project-root", false); return; } } catch { // Directory doesn't exist or can't stat - invalidate cache - db.query("DELETE FROM project_root_cache WHERE cwd = ?").run(cwd); + getDatabase() + .query("DELETE FROM project_root_cache WHERE cwd = ?") + .run(cwd); recordCacheHit("project-root", false); return; } @@ -108,7 +112,9 @@ export async function setCachedProjectRoot( cwd: string, entry: ProjectRootCacheEntry, ): Promise { - const db = getDatabase(); + // Initialize storage before stat in case it creates files in cwd. The + // connection may change while awaiting stat, so reacquire it for the write. + getDatabase(); const now = Date.now(); // Get current mtime of the cwd directory @@ -123,7 +129,7 @@ export async function setCachedProjectRoot( } runUpsert( - db, + getDatabase(), "project_root_cache", { cwd, diff --git a/packages/cli/src/lib/db/regions.ts b/packages/cli/src/lib/db/regions.ts index ec45876fe..8a6158571 100644 --- a/packages/cli/src/lib/db/regions.ts +++ b/packages/cli/src/lib/db/regions.ts @@ -11,6 +11,7 @@ */ import { DEFAULT_SENTRY_URL, getConfiguredSentryUrl } from "../constants.js"; +import { createInvocationState } from "../env.js"; import { logger } from "../logger.js"; import { normalizeHttpOrigin } from "../sentry-urls.js"; import { recordCacheHit } from "../telemetry.js"; @@ -33,7 +34,7 @@ function getActiveSourceOrigin(): string { } /** - * Process-local trust extension: origins that were vouched for by the + * Invocation-local trust extension: origins that were vouched for by the * active token's issuing host (via `/users/me/regions/` responses or * `org_regions` table entries from prior invocations). Used by the * fetch-layer trust check in `token-host.ts` to admit requests to @@ -43,8 +44,11 @@ function getActiveSourceOrigin(): string { * start (with cached orgs from a previous CLI invocation) we don't * re-fetch regions just to extend trust. */ -const trustedRegionOrigins = new Map>(); -const seededTrustScopes = new Set(); +const getRegionState = createInvocationState(() => ({ + trustedRegionOrigins: new Map>(), + seededTrustScopes: new Set(), + orgCacheDisabled: false, +})); function trustScopeKey(identity: string, sourceOrigin: string): string { return `${identity}\0${sourceOrigin}`; @@ -76,6 +80,7 @@ function registerTrustedOrigins( sourceOrigin: string, urls: readonly string[], ): void { + const { trustedRegionOrigins, seededTrustScopes } = getRegionState(); const key = trustScopeKey(identity, sourceOrigin); if ( !trustedRegionOrigins.has(key) && @@ -98,6 +103,7 @@ function registerTrustedOrigins( } function seedTrustedOrigins(identity: string, sourceOrigin: string): void { + const { seededTrustScopes } = getRegionState(); const key = trustScopeKey(identity, sourceOrigin); if (seededTrustScopes.has(key)) { return; @@ -160,6 +166,7 @@ export function isTrustedRegionOrigin( if (!(candidate && source)) { return false; } + const { trustedRegionOrigins } = getRegionState(); const pending = [source]; const visited = new Set(); while (pending.length > 0 && visited.size < MAX_TRUST_GRAPH_ORIGINS) { @@ -191,6 +198,7 @@ export function isTrustedRegionOrigin( * after clearAuth runs during re-auth. */ export function clearTrustedHostState(): void { + const { trustedRegionOrigins, seededTrustScopes } = getRegionState(); trustedRegionOrigins.clear(); seededTrustScopes.clear(); } @@ -200,17 +208,14 @@ export function resetTrustedRegionUrlsForTesting(): void { clearTrustedHostState(); } -/** When true, getCachedOrganizations() returns empty (forces API fetch). */ -let orgCacheDisabled = false; - /** Disable the org listing cache for this invocation (e.g., `--fresh` flag). */ export function disableOrgCache(): void { - orgCacheDisabled = true; + getRegionState().orgCacheDisabled = true; } /** Re-enable the org listing cache. Exported for testing. */ export function enableOrgCache(): void { - orgCacheDisabled = false; + getRegionState().orgCacheDisabled = false; } type OrgRegionRow = { @@ -484,7 +489,7 @@ export function getCachedOrganizations( sourceOrigin = getActiveSourceOrigin(), identity = getIdentityFingerprint(), ): CachedOrg[] { - if (orgCacheDisabled) { + if (getRegionState().orgCacheDisabled) { return []; } diff --git a/packages/cli/src/lib/env-token-host.ts b/packages/cli/src/lib/env-token-host.ts index ef655404a..4434b6a80 100644 --- a/packages/cli/src/lib/env-token-host.ts +++ b/packages/cli/src/lib/env-token-host.ts @@ -36,7 +36,7 @@ import { DEFAULT_SENTRY_URL, normalizeUrl } from "./constants.js"; import { getRawEnvToken } from "./db/auth.js"; -import { getEnv } from "./env.js"; +import { createInvocationState, getEnv } from "./env.js"; import { ConfigError } from "./errors.js"; import { parseSntrysClaim } from "./token-claims.js"; @@ -97,9 +97,9 @@ function captureClaimHost(token: string | undefined): { } } -const snapshotState = { - byEnv: new WeakMap(), -}; +const getHostState = createInvocationState<{ snapshot?: HostSnapshot }>( + () => ({}), +); /** * Snapshot the env-token's scoping host. Idempotent — second and subsequent @@ -114,10 +114,11 @@ const snapshotState = { * 3. `DEFAULT_SENTRY_URL` (SaaS). */ export function captureEnvTokenHost(): void { - const env = getEnv(); - if (snapshotState.byEnv.has(env)) { + const state = getHostState(); + if (state.snapshot) { return; } + const env = getEnv(); const configuredHost = normalizeHost( env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim(), @@ -125,11 +126,11 @@ export function captureEnvTokenHost(): void { ) ?? null; // Claim first: for sntrys_ tokens, the embedded url is authoritative. const claim = captureClaimHost(getRawEnvToken()); - snapshotState.byEnv.set(env, { + state.snapshot = { configuredHost, host: claim.host ?? configuredHost ?? DEFAULT_SENTRY_URL, ...(claim.error ? { claimError: claim.error } : {}), - }); + }; } /** @@ -138,11 +139,8 @@ export function captureEnvTokenHost(): void { * auto-capture covers library-mode callers that bypass the boot. */ export function getEnvTokenHost(): string { - const env = getEnv(); - if (!snapshotState.byEnv.has(env)) { - captureEnvTokenHost(); - } - const snapshot = snapshotState.byEnv.get(env); + captureEnvTokenHost(); + const { snapshot } = getHostState(); if (snapshot?.claimError) { throw snapshot.claimError; } @@ -151,14 +149,11 @@ export function getEnvTokenHost(): string { /** Only the explicit URL captured at boot may migrate a legacy stored login. */ export function getBootConfiguredSentryUrl(): string | undefined { - const env = getEnv(); - if (!snapshotState.byEnv.has(env)) { - captureEnvTokenHost(); - } - return snapshotState.byEnv.get(env)?.configuredHost ?? undefined; + captureEnvTokenHost(); + return getHostState().snapshot?.configuredHost ?? undefined; } /** @internal */ export function resetEnvTokenHostForTesting(): void { - snapshotState.byEnv = new WeakMap(); + getHostState().snapshot = undefined; } diff --git a/packages/cli/src/lib/env.ts b/packages/cli/src/lib/env.ts index fec3f0c28..e8c32b3ad 100644 --- a/packages/cli/src/lib/env.ts +++ b/packages/cli/src/lib/env.ts @@ -2,26 +2,53 @@ * Environment variable registry for CLI/library isolation. * * CLI mode never calls `setEnv()`, so `getEnv()` returns `process.env`. - * Library mode calls `setEnv()` with a merged env copy — the consumer's - * `process.env` is never mutated. + * SDK invocations own an async context, including work that outlives their + * command handler. The consumer's `process.env` is never mutated. */ import { AsyncLocalStorage } from "node:async_hooks"; -const invocationEnvironments = new AsyncLocalStorage(); +type InvocationContext = { env: NodeJS.ProcessEnv }; +const invocationContext = new AsyncLocalStorage(); + const envState: { defaultEnv: NodeJS.ProcessEnv } = { defaultEnv: process.env }; /** Get the active environment. Library mode overrides this; CLI uses process.env. */ export function getEnv(): NodeJS.ProcessEnv { - return invocationEnvironments.getStore() ?? envState.defaultEnv; + return invocationContext.getStore()?.env ?? envState.defaultEnv; } -/** Set the active environment for this invocation. */ +/** Set the fallback environment outside SDK invocations (used by tests). */ export function setEnv(env: NodeJS.ProcessEnv): void { envState.defaultEnv = env; } -/** Isolate overlapping SDK invocations without changing the process environment. */ -export function withEnv(env: NodeJS.ProcessEnv, callback: () => T): T { - return invocationEnvironments.run(env, callback); +/** Run with a captured environment; asynchronous descendants retain it. */ +export function withEnv(env: NodeJS.ProcessEnv, run: () => T): T { + return invocationContext.run({ env }, run); +} + +/** + * Lazily create module-owned state for each SDK invocation, or one shared + * instance for CLI mode. Weak keys let finished invocations be collected + * without clearing state still needed by pending requests. + */ +export function createInvocationState( + create: () => T, +): () => T { + const states = new WeakMap(); + let cliState: T | undefined; + return () => { + const context = invocationContext.getStore(); + if (!context) { + cliState ??= create(); + return cliState; + } + let state = states.get(context); + if (!state) { + state = create(); + states.set(context, state); + } + return state; + }; } diff --git a/packages/cli/src/lib/issue-links.ts b/packages/cli/src/lib/issue-links.ts index 004caf57e..0a5add3bc 100644 --- a/packages/cli/src/lib/issue-links.ts +++ b/packages/cli/src/lib/issue-links.ts @@ -19,6 +19,7 @@ import { resolveNativeIssueLink, unlinkNativeIssueLink, } from "./api/issue-integrations.js"; +import { getIdentityFingerprint } from "./db/auth.js"; import { ValidationError } from "./errors.js"; import { resolveOrgRegion } from "./region.js"; import { invalidateCachedResponsesMatching } from "./response-cache.js"; @@ -229,12 +230,17 @@ async function invalidateIssueLinks( const regionUrl = await resolveOrgRegion(options.orgSlug); const base = getApiBaseUrl(); const issuePath = `/api/0/organizations/${encodeURIComponent(options.orgSlug)}/issues/${encodeURIComponent(options.issueId)}/`; + const identity = getIdentityFingerprint(); await Promise.all([ - invalidateCachedResponsesMatching(new URL(issuePath, regionUrl).href), - invalidateCachedResponsesMatching(new URL(issuePath, base).href), + invalidateCachedResponsesMatching( + new URL(issuePath, regionUrl).href, + identity, + ), + invalidateCachedResponsesMatching(new URL(issuePath, base).href, identity), invalidateCachedResponsesMatching( new URL(`/api/0/issues/${encodeURIComponent(options.issueId)}/`, base) .href, + identity, ), ]); } diff --git a/packages/cli/src/lib/region.ts b/packages/cli/src/lib/region.ts index 94867c288..69a83cee9 100644 --- a/packages/cli/src/lib/region.ts +++ b/packages/cli/src/lib/region.ts @@ -9,6 +9,7 @@ import { getOrganization } from "@sentry/api"; import { type CredentialContext, getCredentialContext } from "./db/auth.js"; import { getOrgByNumericId, getOrgRegion, setOrgRegion } from "./db/regions.js"; import { stripDsnOrgPrefix } from "./dsn/index.js"; +import { createInvocationState } from "./env.js"; import { AuthError, withAuthGuard } from "./errors.js"; import { logger } from "./logger.js"; import { @@ -24,7 +25,7 @@ import { } from "./sentry-urls.js"; /** - * Promise cache for org region resolution, keyed by orgSlug. + * Invocation-local promise cache, keyed by identity, base URL, and orgSlug. * * When multiple DSNs share an orgId, concurrent calls to resolveOrgRegion * deduplicate into a single HTTP request. A resolved promise returns @@ -34,7 +35,9 @@ import { * retries after re-authentication can succeed without restarting the CLI. */ type RegionResolution = { cacheable: boolean; url: string }; -const regionCache = new Map>(); +const getRegionCache = createInvocationState( + () => new Map>(), +); /** * Resolve the region URL for an organization. @@ -61,6 +64,7 @@ export function resolveOrgRegion(orgSlug: string): Promise { } const baseUrl = getApiBaseUrl(credential); const key = `${credential.identity}\0${baseUrl}\0${orgSlug}`; + const regionCache = getRegionCache(); const existing = regionCache.get(key); if (existing) { return existing.then((resolution) => resolution.url); @@ -112,7 +116,7 @@ function getResolvedRegionUrl( /** * Resolve org region from SQLite cache or API. - * Called at most once per orgSlug per process lifetime. + * Successful resolutions are reused within the current invocation and identity. */ async function resolveOrgRegionUncached( orgSlug: string, diff --git a/packages/cli/src/lib/response-cache.ts b/packages/cli/src/lib/response-cache.ts index 6019c1e62..e15c0c3e4 100644 --- a/packages/cli/src/lib/response-cache.ts +++ b/packages/cli/src/lib/response-cache.ts @@ -29,9 +29,8 @@ import { join } from "node:path"; import CachePolicy from "http-cache-semantics"; import pLimit from "p-limit"; -import { getIdentityFingerprint } from "./db/auth.js"; import { getConfigDir } from "./db/index.js"; -import { getEnv } from "./env.js"; +import { createInvocationState, getEnv } from "./env.js"; import { logger } from "./logger.js"; import { recordCacheHit, withCacheSpan } from "./telemetry.js"; @@ -111,7 +110,7 @@ export function classifyUrl(url: string): TtlTier { // --------------------------------------------------------------------------- /** - * Build a deterministic cache key from the active identity + method + URL. + * Build a deterministic cache key from the supplied identity + method + URL. * * Query params are sorted alphabetically so `?a=1&b=2` and `?b=2&a=1` * produce the same key. The identity fingerprint scopes entries per @@ -122,7 +121,7 @@ export function classifyUrl(url: string): TtlTier { export function buildCacheKey( method: string, url: string, - identity = getIdentityFingerprint(), + identity: string, ): string { const normalized = normalizeUrl(method, url); return createHmac("sha256", identity).update(normalized).digest("hex"); @@ -319,7 +318,7 @@ function buildResponseHeaders( } // --------------------------------------------------------------------------- -// Last cache-hit age — process-global signal for cache-age hints +// Last cache-hit age — invocation-local signal for cache-age hints // --------------------------------------------------------------------------- /** @@ -329,16 +328,19 @@ function buildResponseHeaders( * `authenticatedFetch` call in `sentry-client.ts`. Commands read it via * {@link getLastCacheHitAge} to show "cached · 3m ago · use -f to refresh". * - * Safe because the CLI is single-process, single-command — no races. + * Scoped to the command so late requests cannot affect the next SDK call. */ -let lastCacheHitAgeMs: number | undefined; +const getCacheState = createInvocationState<{ + lastCacheHitAgeMs?: number; + cacheReadBypassed: boolean; +}>(() => ({ cacheReadBypassed: false })); /** * Get the age (in ms) of the most recent cache hit, or `undefined` if the * last request was not served from cache. */ export function getLastCacheHitAge(): number | undefined { - return lastCacheHitAgeMs; + return getCacheState().lastCacheHitAgeMs; } /** @@ -346,7 +348,7 @@ export function getLastCacheHitAge(): number | undefined { * call so the signal reflects only the current request. */ export function clearLastCacheHitAge(): void { - lastCacheHitAgeMs = undefined; + getCacheState().lastCacheHitAgeMs = undefined; } /** @@ -356,23 +358,21 @@ export function clearLastCacheHitAge(): void { * @internal Exported for testing */ export function setLastCacheHitAgeForTesting(ageMs: number): void { - lastCacheHitAgeMs = ageMs; + getCacheState().lastCacheHitAgeMs = ageMs; } // --------------------------------------------------------------------------- // Cache bypass control // --------------------------------------------------------------------------- -let cacheReadBypassed = false; - /** - * Bypass cache reads for the current process. + * Bypass cache reads for the current invocation. * * Called when `--fresh` flag is passed to a command. Fresh API responses are * still written to cache so subsequent invocations serve updated data. */ export function disableResponseCache(): void { - cacheReadBypassed = true; + getCacheState().cacheReadBypassed = true; } /** @@ -385,7 +385,7 @@ export function disableResponseCache(): void { * @internal Exported for testing */ export function resetCacheState(): void { - cacheReadBypassed = false; + getCacheState().cacheReadBypassed = false; } /** @@ -395,7 +395,7 @@ export function resetCacheState(): void { * - `SENTRY_NO_CACHE=1` environment variable is set */ export function isCacheDisabled(): boolean { - return cacheReadBypassed || getEnv().SENTRY_NO_CACHE === "1"; + return getCacheState().cacheReadBypassed || getEnv().SENTRY_NO_CACHE === "1"; } /** @@ -413,6 +413,13 @@ function isCacheWriteDisabled(): boolean { // Public API // --------------------------------------------------------------------------- +/** Request metadata captured when credentials are selected, before async work. */ +export type CacheRequest = { + headers: Record; + /** Opaque owner selected by the caller; the cache never resolves credentials. */ + identity: string; +}; + /** * Attempt to serve a cached response for a GET request. * @@ -422,14 +429,13 @@ function isCacheWriteDisabled(): boolean { * * @param method - HTTP method (only "GET" is cached) * @param url - Full request URL - * @param requestHeaders - Headers from the new request + * @param request - Headers and identity of the new request * @returns A synthetic Response if cache hit, or undefined on miss/expired */ export async function getCachedResponse( method: string, url: string, - requestHeaders: Record, - identity = getIdentityFingerprint(), + { headers: requestHeaders, identity }: CacheRequest, ): Promise { if ( method !== "GET" || @@ -474,7 +480,7 @@ export async function getCachedResponse( span.setAttribute("cache.item_size", body.length); // Surface cache age for command-level hints (getsentry/cli#785 #1) - lastCacheHitAgeMs = Date.now() - entry.createdAt; + getCacheState().lastCacheHitAgeMs = Date.now() - entry.createdAt; const responseHeaders = buildResponseHeaders(policy, entry); return new Response(body, { @@ -539,15 +545,14 @@ async function readCacheEntry(key: string): Promise { * * @param method - HTTP method * @param url - Full request URL - * @param requestHeaders - Request headers + * @param request - Headers and identity used for the request * @param response - The fetch Response to cache (must be cloned before passing) */ export async function storeCachedResponse( method: string, url: string, - requestHeaders: Record, + { headers: requestHeaders, identity }: CacheRequest, response: Response, - identity = getIdentityFingerprint(), ): Promise { if ( method !== "GET" || @@ -693,7 +698,7 @@ async function writeResponseToCache(req: WriteRequest): Promise { /** * Invalidate every cached GET whose URL starts with `prefix` and - * belongs to the current identity. Best-effort; never throws. + * belongs to the supplied identity. Best-effort; never throws. * * Cache filenames already scope entries by identity (see * {@link buildCacheKey}), but a prefix sweep has to read every file @@ -704,7 +709,7 @@ async function writeResponseToCache(req: WriteRequest): Promise { */ export async function invalidateCachedResponsesMatching( prefix: string, - identity = getIdentityFingerprint(), + identity: string, ): Promise { // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { diff --git a/packages/cli/src/lib/sdk-invoke.ts b/packages/cli/src/lib/sdk-invoke.ts index 1f5f131a1..5de782bc5 100644 --- a/packages/cli/src/lib/sdk-invoke.ts +++ b/packages/cli/src/lib/sdk-invoke.ts @@ -19,6 +19,7 @@ import type { Span } from "@sentry/core"; import type { Writer } from "../types/index.js"; import { type AsyncChannel, createAsyncChannel } from "./async-channel.js"; import { withEnv } from "./env.js"; +import { EXIT } from "./errors.js"; import { SentryError, type SentryOptions } from "./sdk-types.js"; /** CLI flag names/aliases that trigger infinite streaming output. */ @@ -193,6 +194,50 @@ export function applyFlagDefaults( // oxlint-disable-next-line no-control-regex -- ANSI escape sequences use ESC (0x1b) const ANSI_RE = /\x1b\[[0-9;]*m/g; +/** Command output and telemetry still require sequential SDK invocations. */ +let invocationActive = false; + +/** + * Capture caller inputs synchronously and keep auth/routing state with their + * async descendants, even if a failed command leaves requests in flight. + */ +async function withSdkEnvironment( + options: SentryOptions | undefined, + execute: (env: NodeJS.ProcessEnv, cwd: string) => Promise, +): Promise { + if (invocationActive) { + throw new SentryError( + "Concurrent SDK calls are not supported. Await the previous call or close its stream first.", + EXIT.GENERAL, + "", + ); + } + invocationActive = true; + + try { + const env = buildIsolatedEnv(options); + const cwd = options?.cwd ?? process.cwd(); + const customHeaders = options?.headers && { ...options.headers }; + return await withEnv(env, async () => { + // Keep SQLite out of the SDK import path. + const [envTokenHost, headers] = await Promise.all([ + import("./env-token-host.js"), + import("./custom-headers.js"), + ]); + envTokenHost.captureEnvTokenHost(); + return headers.withCustomHeadersOverride(customHeaders, () => + execute(env, cwd), + ); + }); + } catch (thrown) { + throw thrown instanceof SentryError + ? thrown + : buildSdkError([], extractExitCode(thrown) || EXIT.GENERAL, thrown); + } finally { + invocationActive = false; + } +} + /** Flush Sentry telemetry (no beforeExit handler in library mode). */ async function flushTelemetry(): Promise { // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. @@ -393,56 +438,51 @@ async function buildCaptureContext( * and output parsing. Both public factories become thin wrappers that * only provide the executor callback. */ -async function executeWithCapture( +function executeWithCapture( options: SentryOptions | undefined, executor: ( captureCtx: CaptureContext, span: Span | undefined, ) => Promise, ): Promise { - const env = buildIsolatedEnv(options); - const cwd = options?.cwd ?? process.cwd(); - return await withEnv(env, async () => { - const { withCustomHeadersOverride } = await import("./custom-headers.js"); - return await withCustomHeadersOverride(options?.headers, async () => { - const captureCtx = await buildCaptureContext(env, cwd); - const { withTelemetry } = await import("./telemetry.js"); - - try { - await withTelemetry(async (span) => executor(captureCtx, span), { - libraryMode: true, - }); - } catch (thrown) { - await flushTelemetry(); - - // OutputError: data was already rendered (captured) before the throw. - // Return it despite the non-zero exit code — this is the "HTTP 404 body" - // pattern where the data is useful even though the operation "failed". - const captured = captureCtx.getCapturedResult(); - if (captured !== undefined) { - return captured as T; - } - - const exitCode = - extractExitCode(thrown) || captureCtx.context.process.exitCode || 1; - throw buildSdkError(captureCtx.stderrChunks, exitCode, thrown); - } + return withSdkEnvironment(options, async (env, cwd) => { + const captureCtx = await buildCaptureContext(env, cwd); + const { withTelemetry } = await import("./telemetry.js"); + try { + await withTelemetry(async (span) => executor(captureCtx, span), { + libraryMode: true, + }); + } catch (thrown) { await flushTelemetry(); - // Check exit code (Stricli sets it without throwing for some errors) - if (captureCtx.context.process.exitCode !== 0) { - throw buildSdkError( - captureCtx.stderrChunks, - captureCtx.context.process.exitCode, - ); + // OutputError: data was already rendered (captured) before the throw. + // Return it despite the non-zero exit code — this is the "HTTP 404 body" + // pattern where the data is useful even though the operation "failed". + const captured = captureCtx.getCapturedResult(); + if (captured !== undefined) { + return captured as T; } - return parseOutput( - captureCtx.getCapturedResult(), - captureCtx.stdoutChunks, + const exitCode = + extractExitCode(thrown) || captureCtx.context.process.exitCode || 1; + throw buildSdkError(captureCtx.stderrChunks, exitCode, thrown); + } + + await flushTelemetry(); + + // Check exit code (Stricli sets it without throwing for some errors) + if (captureCtx.context.process.exitCode !== 0) { + throw buildSdkError( + captureCtx.stderrChunks, + captureCtx.context.process.exitCode, ); - }); + } + + return parseOutput( + captureCtx.getCapturedResult(), + captureCtx.stdoutChunks, + ); }); } @@ -462,52 +502,53 @@ function executeWithStream( ) => Promise, ): AsyncChannel { const controller = new AbortController(); + const abort = () => controller.abort(); // Cascade external signal to our controller if (options?.signal) { if (options.signal.aborted) { controller.abort(); } else { - options.signal.addEventListener("abort", () => controller.abort(), { - once: true, - }); + options.signal.addEventListener("abort", abort, { once: true }); } } const channel = createAsyncChannel({ - onReturn: () => controller.abort(), + onReturn: async () => { + controller.abort(); + await completion; + }, }); - // Fire-and-forget — command runs in background - const env = buildIsolatedEnv(options); - const invocation = withEnv(env, async () => { - const cwd = options?.cwd ?? process.cwd(); - + // The producer owns the environment until it has fully stopped, including + // when the consumer exits early. Terminal results are published after cleanup. + const completion = (async () => { let captureCtx: CaptureContext | undefined; try { - const { withCustomHeadersOverride } = await import("./custom-headers.js"); - await withCustomHeadersOverride(options?.headers, async () => { - captureCtx = await buildCaptureContext(env, cwd, { - channel: channel as AsyncChannel, - abortSignal: controller.signal, - }); - - const { withTelemetry } = await import("./telemetry.js"); - - // oxlint-disable-next-line typescript/no-non-null-assertion -- captureCtx is assigned on the line above - await withTelemetry(async (span) => executor(captureCtx!, span), { - libraryMode: true, - }); - - // Check exit code — Stricli sets it without throwing for some errors - if (captureCtx.context.process.exitCode !== 0) { - channel.error( - buildSdkError( + await withSdkEnvironment(options, async (env, cwd) => { + try { + if (controller.signal.aborted) { + return; + } + captureCtx = await buildCaptureContext(env, cwd, { + channel: channel as AsyncChannel, + abortSignal: controller.signal, + }); + + const { withTelemetry } = await import("./telemetry.js"); + + // oxlint-disable-next-line typescript/no-non-null-assertion -- captureCtx is assigned on the line above + await withTelemetry(async (span) => executor(captureCtx!, span), { + libraryMode: true, + }); + + // Check exit code — Stricli sets it without throwing for some errors + if (captureCtx.context.process.exitCode !== 0) { + throw buildSdkError( captureCtx.stderrChunks, captureCtx.context.process.exitCode, - ), - ); - } else { + ); + } // Drain any raw stdout the command wrote directly (via stdout.write) // instead of yielding via captureObject — e.g. a binary Uint8Array // body. Without this, those bytes accumulate in stdoutChunks and are @@ -518,9 +559,11 @@ function executeWithStream( if (trailing !== undefined) { channel.push(trailing); } - channel.close(); + } finally { + await flushTelemetry(); } }); + channel.close(); } catch (thrown) { const stderrChunks = captureCtx?.stderrChunks ?? []; const exitCode = @@ -531,12 +574,9 @@ function executeWithStream( : buildSdkError(stderrChunks, exitCode, thrown); channel.error(err); } finally { - await flushTelemetry(); + options?.signal?.removeEventListener("abort", abort); } - }); - invocation.catch((error: unknown) => { - channel.error(error instanceof Error ? error : buildSdkError([], 1, error)); - }); + })(); return channel; } diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index 996d172c9..dd0a81eac 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -460,7 +460,10 @@ async function tryCacheHit( if (method !== "GET") { return; } - return await getCachedResponse(method, fullUrl, requestHeaders, identity); + return await getCachedResponse(method, fullUrl, { + headers: requestHeaders, + identity, + }); } /** @@ -488,9 +491,8 @@ function cacheResponse( storeCachedResponse( method, fullUrl, - requestHeaders, + { headers: requestHeaders, identity }, response.clone() as Response, - identity, ).catch((error) => { log.debug("Response cache write failed", error); }); diff --git a/packages/cli/src/lib/token-host.ts b/packages/cli/src/lib/token-host.ts index b1158bc7b..20b4a2646 100644 --- a/packages/cli/src/lib/token-host.ts +++ b/packages/cli/src/lib/token-host.ts @@ -21,6 +21,7 @@ import { getIdentityFingerprint, } from "./db/auth.js"; import { isTrustedRegionOrigin } from "./db/regions.js"; +import { createInvocationState } from "./env.js"; import { isSaaSTrustOrigin, normalizeOrigin } from "./sentry-urls.js"; /** @@ -61,7 +62,7 @@ export function getActiveTokenHost(): string | undefined { } /** - * Process-local login trust anchor — set by `applyLoginUrl` from `--url` or + * Invocation-local login trust anchor — set by `applyLoginUrl` from `--url` or * the boot-time env snapshot. Used by {@link isRequestOriginTrustedForCustomHeaders} * during the no-token bootstrap window so OAuth device-flow requests against * IAP-protected self-hosted instances can carry `SENTRY_CUSTOM_HEADERS`. @@ -71,30 +72,32 @@ export function getActiveTokenHost(): string | undefined { * `.sentryclirc` shim does NOT register an anchor — only explicit `--url` or * boot-time env values do. */ -let loginTrustAnchor: string | undefined; +const getLoginState = createInvocationState<{ loginTrustAnchor?: string }>( + () => ({}), +); /** Register an explicit login-time trust anchor. URLs are normalized. */ export function registerLoginTrustAnchor(url: string): void { const origin = normalizeOrigin(url); if (origin) { - loginTrustAnchor = origin; + getLoginState().loginTrustAnchor = origin; } } /** - * Whether the current process's login trust anchor matches `host` under the + * Whether the current invocation's login trust anchor matches `host` under the * host-scoping trust model (exact origin or SaaS equivalence). The match * check is load-bearing: an existence-only check would let a stale anchor * from a prior `auth login --url ` (in library/test mode) admit * a login against a different host. */ export function isLoginTrustAnchorFor(host: string): boolean { - return isHostTrusted(host, loginTrustAnchor); + return isHostTrusted(host, getLoginState().loginTrustAnchor); } /** @internal exported for testing */ export function resetLoginTrustAnchorForTesting(): void { - loginTrustAnchor = undefined; + getLoginState().loginTrustAnchor = undefined; } /** @@ -164,6 +167,7 @@ export function isRequestOriginTrustedForCustomHeaders( if (getActiveTokenHost()) { return isRequestOriginTrusted(requestInput); } + const { loginTrustAnchor } = getLoginState(); if (loginTrustAnchor) { return isHostTrusted(requestInput, loginTrustAnchor); } diff --git a/packages/cli/test/helpers.ts b/packages/cli/test/helpers.ts index d8a19c112..3d53ce51b 100644 --- a/packages/cli/test/helpers.ts +++ b/packages/cli/test/helpers.ts @@ -9,7 +9,6 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, beforeEach } from "vitest"; import { - resetAuthRowCache, resetAuthTokenCache, resetHasStoredCredsCache, resetIdentityFingerprintCache, @@ -113,7 +112,6 @@ export function useTestConfigDir( closeDatabase(); // Fresh DB — drop module-scoped auth caches from the previous test. resetAuthTokenCache(); - resetAuthRowCache(); resetHasStoredCredsCache(); resetIdentityFingerprintCache(); dir = await createTestConfigDir(prefix, options); @@ -123,7 +121,6 @@ export function useTestConfigDir( afterEach(async () => { closeDatabase(); resetAuthTokenCache(); - resetAuthRowCache(); resetHasStoredCredsCache(); resetIdentityFingerprintCache(); // Always restore the previous value — never delete. diff --git a/packages/cli/test/lib/api/issues.test.ts b/packages/cli/test/lib/api/issues.test.ts index 4ceab3d75..fb99df169 100644 --- a/packages/cli/test/lib/api/issues.test.ts +++ b/packages/cli/test/lib/api/issues.test.ts @@ -13,7 +13,10 @@ import { RESOLVE_COMMIT_SENTINEL, RESOLVE_NEXT_RELEASE_SENTINEL, } from "../../../src/lib/api-client.js"; -import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { + getIdentityFingerprint, + setAuthToken, +} from "../../../src/lib/db/auth.js"; import { ApiError, ValidationError } from "../../../src/lib/errors.js"; import { getCachedResponse, @@ -268,13 +271,18 @@ describe("mergeIssues: cross-origin legacy cache", () => { await storeCachedResponse( "GET", legacyUrl(id), - {}, + { identity: getIdentityFingerprint(), headers: {} }, new Response(JSON.stringify({ id }), { status: 200, headers: { "content-type": "application/json" }, }), ); - expect(await getCachedResponse("GET", legacyUrl(id), {})).toBeDefined(); + expect( + await getCachedResponse("GET", legacyUrl(id), { + identity: getIdentityFingerprint(), + headers: {}, + }), + ).toBeDefined(); } globalThis.fetch = mockFetch( @@ -290,7 +298,12 @@ describe("mergeIssues: cross-origin legacy cache", () => { await mergeIssues("test-org", ["100", "200", "300"]); for (const id of ["100", "200", "300"]) { - expect(await getCachedResponse("GET", legacyUrl(id), {})).toBeUndefined(); + expect( + await getCachedResponse("GET", legacyUrl(id), { + identity: getIdentityFingerprint(), + headers: {}, + }), + ).toBeUndefined(); } }); }); diff --git a/packages/cli/test/lib/api/organizations-context.focused.test.ts b/packages/cli/test/lib/api/organizations-context.focused.test.ts index ca0b66d63..c72483fa8 100644 --- a/packages/cli/test/lib/api/organizations-context.focused.test.ts +++ b/packages/cli/test/lib/api/organizations-context.focused.test.ts @@ -4,7 +4,10 @@ import { listOrganizations, listOrganizationsUncached, } from "../../../src/lib/api/organizations.js"; -import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { + getIdentityFingerprint, + setAuthToken, +} from "../../../src/lib/db/auth.js"; import { getDatabase } from "../../../src/lib/db/index.js"; import { clearOrgRegions, @@ -426,7 +429,10 @@ describe("organization discovery credential context", () => { await storeCachedResponse( "GET", "https://sentry.io/api/0/organizations/?per_page=100", - { authorization: `Bearer ${token}` }, + { + headers: { authorization: `Bearer ${token}` }, + identity: getIdentityFingerprint(), + }, Response.json([{ id: "9", slug: "cached-org", name: "Cached" }], { headers: { "Cache-Control": "public, max-age=300" }, }), diff --git a/packages/cli/test/lib/async-channel.test.ts b/packages/cli/test/lib/async-channel.test.ts index b42a464f3..6c50ea46a 100644 --- a/packages/cli/test/lib/async-channel.test.ts +++ b/packages/cli/test/lib/async-channel.test.ts @@ -118,6 +118,32 @@ describe("createAsyncChannel", () => { expect(results).toEqual([1]); }); + test("return waits for producer cleanup before completing pending reads", async () => { + let finishCleanup: (() => void) | undefined; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + const ch = createAsyncChannel({ onReturn: () => cleanup }); + const iterator = ch[Symbol.asyncIterator](); + let readDone = false; + const pendingRead = iterator.next().then((result) => { + readDone = true; + return result; + }); + let returnDone = false; + const returning = iterator.return!().then((result) => { + returnDone = true; + return result; + }); + + await Promise.resolve(); + expect(readDone).toBe(false); + expect(returnDone).toBe(false); + finishCleanup?.(); + expect((await returning).done).toBe(true); + expect((await pendingRead).done).toBe(true); + }); + test("push after error is a silent no-op", async () => { const ch = createAsyncChannel(); const iter = ch[Symbol.asyncIterator](); diff --git a/packages/cli/test/lib/db/auth.property.test.ts b/packages/cli/test/lib/db/auth.property.test.ts index b21935e0a..8b5db0beb 100644 --- a/packages/cli/test/lib/db/auth.property.test.ts +++ b/packages/cli/test/lib/db/auth.property.test.ts @@ -23,7 +23,6 @@ import { getAuthToken, isEnvTokenActive, refreshToken, - resetAuthRowCache, resetAuthTokenCache, setAuthToken, } from "../../../src/lib/db/auth.js"; @@ -44,7 +43,6 @@ const storedTokenArb = stringMatching(/^[\x21-\x7e]{1,100}$/); /** Invalidate between property iterations — env-var mutations bypass setAuthToken. */ function resetAuthCaches() { resetAuthTokenCache(); - resetAuthRowCache(); } describe("property: env var priority", () => { diff --git a/packages/cli/test/lib/db/auth.test.ts b/packages/cli/test/lib/db/auth.test.ts index 708e12fc6..1d55461f9 100644 --- a/packages/cli/test/lib/db/auth.test.ts +++ b/packages/cli/test/lib/db/auth.test.ts @@ -7,13 +7,17 @@ * by property tests (isAuthenticated, getActiveEnvVarName). */ -import { describe, expect, test } from "vitest"; +import { symlink } from "node:fs/promises"; +import { join } from "node:path"; +import { setImmediate } from "node:timers/promises"; +import { describe, expect, test, vi } from "vitest"; import { ANON_IDENTITY, clearAuth, getActiveEnvVarName, getAuthConfig, getAuthToken, + getCredentialContext, getIdentityFingerprint, getRawEnvToken, hasStoredAuthCredentials, @@ -26,10 +30,11 @@ import { setAuthToken, } from "../../../src/lib/db/auth.js"; import { getDatabase } from "../../../src/lib/db/index.js"; +import { withEnv } from "../../../src/lib/env.js"; import { MalformedAuthTokenError } from "../../../src/lib/errors.js"; -import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; +import { mockFetch, useEnvSandbox, useTestConfigDir } from "../../helpers.js"; -useTestConfigDir("auth-env-"); +const getConfigDir = useTestConfigDir("auth-env-"); useEnvSandbox(["SENTRY_AUTH_TOKEN", "SENTRY_TOKEN", "SENTRY_FORCE_ENV_TOKEN"]); describe("env var auth: getAuthToken edge cases", () => { @@ -407,10 +412,222 @@ describe("getAuthToken memoization", () => { }); }); -describe("refreshToken row-read memoization", () => { +describe("refreshToken stored session updates", () => { + test.each(["config alias", "legacy host"] as const)( + "shares an in-flight refresh across a %s", + async (variant) => { + const configDir = getConfigDir(); + const secondConfigDir = + variant === "config alias" ? join(configDir, "alias") : configDir; + if (variant === "config alias") { + await symlink(configDir, secondConfigDir, "junction"); + } + const originalFetch = globalThis.fetch; + const refreshTokens: (string | null)[] = []; + const host = "https://synthetic.example.invalid"; + const env = { ...process.env, SENTRY_URL: host, SENTRY_HOST: host }; + let markStarted: (() => void) | undefined; + const refreshStarted = new Promise((resolve) => { + markStarted = resolve; + }); + let releaseRefresh: (() => void) | undefined; + const refreshGate = new Promise((resolve) => { + releaseRefresh = resolve; + }); + const pending: Promise[] = []; + + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + expect(request.url).toBe(`${host}/oauth/token/`); + refreshTokens.push( + new URLSearchParams(await request.text()).get("refresh_token"), + ); + const duplicate = refreshTokens.length > 1; + markStarted?.(); + await refreshGate; + if (duplicate) { + return Response.json({ error: "invalid_grant" }, { status: 400 }); + } + return Response.json({ + access_token: "synthetic-new-token", + refresh_token: "synthetic-new-refresh", + expires_in: 3600, + token_type: "bearer", + }); + }); + + try { + setAuthToken( + "synthetic-original-token", + 3600, + "synthetic-original-refresh", + { + host, + }, + ); + if (variant === "legacy host") { + // OAuth's trust check migrates pre-v16 rows while refresh is pending. + getDatabase().query("UPDATE auth SET host = NULL WHERE id = 1").run(); + } + pending.push(withEnv(env, () => refreshToken({ force: true }))); + await refreshStarted; + pending.push( + withEnv({ ...env, SENTRY_CONFIG_DIR: secondConfigDir }, () => + refreshToken({ force: true }), + ), + ); + // Let the second invocation reach the pending refresh before releasing it. + await setImmediate(); + const completed = Promise.all(pending); + releaseRefresh?.(); + await expect(completed).resolves.toMatchObject([ + { token: "synthetic-new-token", refreshed: true }, + { token: "synthetic-new-token", refreshed: true }, + ]); + expect(refreshTokens).toEqual(["synthetic-original-refresh"]); + expect(getAuthConfig()).toMatchObject({ + token: "synthetic-new-token", + refreshToken: "synthetic-new-refresh", + }); + } finally { + releaseRefresh?.(); + await Promise.allSettled(pending); + globalThis.fetch = originalFetch; + } + }, + ); + + test("keeps refresh identity aliases scoped to their credential store", async () => { + const host = "https://synthetic.example.invalid"; + const env = { ...process.env, SENTRY_URL: host, SENTRY_HOST: host }; + const otherEnv = { + ...env, + SENTRY_CONFIG_DIR: join(getConfigDir(), "other-store"), + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = mockFetch(() => + Response.json({ + access_token: "synthetic-new-token", + refresh_token: "synthetic-new-refresh", + expires_in: 3600, + token_type: "bearer", + }), + ); + try { + const original = withEnv(env, () => { + setAuthToken( + "synthetic-original-token", + 3600, + "synthetic-original-refresh", + { + host, + }, + ); + return getCredentialContext(); + }); + expect(original).toBeDefined(); + await withEnv(env, () => refreshToken({ force: true })); + + await withEnv(otherEnv, async () => { + setAuthToken( + "synthetic-original-token", + 3600, + "synthetic-original-refresh", + { + host, + }, + ); + const expectedCredential = getCredentialContext(); + setAuthToken("synthetic-new-token", 3600, "synthetic-new-refresh", { + host, + }); + // A rotation in another store cannot authorize this store's new login. + await expect(refreshToken({ expectedCredential })).rejects.toThrow( + "Active credentials changed", + ); + }); + + // Login in the other store must not erase this session's refresh lineage. + await expect( + withEnv(env, () => refreshToken({ expectedCredential: original })), + ).resolves.toMatchObject({ + token: "synthetic-new-token", + refreshed: false, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("uses a rotated session when another invocation reaches the refresh threshold", async () => { + let now = Date.now(); + const clock = vi.spyOn(Date, "now").mockImplementation(() => now); + const originalFetch = globalThis.fetch; + const refreshTokens: (string | null)[] = []; + const host = "https://synthetic.example.invalid"; + const env = { ...process.env, SENTRY_URL: host, SENTRY_HOST: host }; + + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + expect(request.url).toBe(`${host}/oauth/token/`); + refreshTokens.push( + new URLSearchParams(await request.text()).get("refresh_token"), + ); + if (refreshTokens.length > 1) { + return Response.json({ error: "invalid_grant" }, { status: 400 }); + } + return Response.json({ + access_token: "synthetic-new-token", + refresh_token: "synthetic-new-refresh", + expires_in: 3600, + token_type: "bearer", + }); + }); + + try { + setAuthToken( + "synthetic-original-token", + 3600, + "synthetic-original-refresh", + { + host, + }, + ); + // Start just above the 10% refresh threshold for a one-hour session. + getDatabase() + .query("UPDATE auth SET issued_at = ?, expires_at = ? WHERE id = 1") + .run(now - 3239 * 1000, now + 361 * 1000); + + await withEnv(env, async () => { + await expect(refreshToken()).resolves.toMatchObject({ + token: "synthetic-original-token", + refreshed: false, + }); + + await withEnv(env, () => refreshToken({ force: true })); + now += 2000; + + // A paginated request may continue after another invocation rotated + // this session; it must not spend the already-consumed refresh token. + await expect(refreshToken()).resolves.toMatchObject({ + token: "synthetic-new-token", + refreshed: false, + }); + }); + + expect(refreshTokens).toEqual(["synthetic-original-refresh"]); + expect(getAuthConfig()).toMatchObject({ + token: "synthetic-new-token", + refreshToken: "synthetic-new-refresh", + }); + } finally { + globalThis.fetch = originalFetch; + clock.mockRestore(); + } + }); + test("setAuthToken between refreshToken calls is reflected", async () => { - // refreshToken reads the full row; invalidation must propagate so the - // second call sees the freshly stored token. + // The next request must use the newly stored session. setAuthToken("first_token", 3600, "refresh_1"); const r1 = await refreshToken(); expect(r1.token).toBe("first_token"); @@ -420,7 +637,7 @@ describe("refreshToken row-read memoization", () => { expect(r2.token).toBe("second_token"); }); - test("clearAuth invalidates the row cache", async () => { + test("clearAuth is reflected by the next request", async () => { setAuthToken("will_be_cleared", 3600, "refresh_x"); const r1 = await refreshToken(); expect(r1.token).toBe("will_be_cleared"); diff --git a/packages/cli/test/lib/db/model-based.test.ts b/packages/cli/test/lib/db/model-based.test.ts index 955afeaeb..435a88cb0 100644 --- a/packages/cli/test/lib/db/model-based.test.ts +++ b/packages/cli/test/lib/db/model-based.test.ts @@ -38,7 +38,6 @@ import { getIdentityFingerprint, isAuthenticated, isEnvTokenActive, - resetAuthRowCache, resetAuthTokenCache, resetIdentityFingerprintCache, setAuthToken, @@ -306,7 +305,6 @@ class SetEnvAuthTokenCommand implements AsyncCommand { process.env.SENTRY_AUTH_TOKEN = this.token; // Env mutation bypasses setAuthToken's invalidation. resetAuthTokenCache(); - resetAuthRowCache(); resetIdentityFingerprintCache(); // Model stores trimmed value — matches real getEnvToken() which trims const trimmed = this.token.trim(); @@ -322,7 +320,6 @@ class ClearEnvAuthTokenCommand implements AsyncCommand { async run(model: DbModel, _real: RealDb): Promise { delete process.env.SENTRY_AUTH_TOKEN; resetAuthTokenCache(); - resetAuthRowCache(); resetIdentityFingerprintCache(); model.envAuthToken = null; } @@ -342,7 +339,6 @@ class SetEnvSentryTokenCommand implements AsyncCommand { async run(model: DbModel, _real: RealDb): Promise { process.env.SENTRY_TOKEN = this.token; resetAuthTokenCache(); - resetAuthRowCache(); resetIdentityFingerprintCache(); // Model stores trimmed value — matches real getEnvToken() which trims const trimmed = this.token.trim(); @@ -358,7 +354,6 @@ class ClearEnvSentryTokenCommand implements AsyncCommand { async run(model: DbModel, _real: RealDb): Promise { delete process.env.SENTRY_TOKEN; resetAuthTokenCache(); - resetAuthRowCache(); resetIdentityFingerprintCache(); model.envSentryToken = null; } @@ -804,7 +799,6 @@ describe("model-based: database layer", () => { delete process.env.SENTRY_AUTH_TOKEN; delete process.env.SENTRY_TOKEN; resetAuthTokenCache(); - resetAuthRowCache(); resetIdentityFingerprintCache(); try { const setup = () => ({ @@ -941,7 +935,6 @@ describe("model-based: database layer", () => { const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; delete process.env.SENTRY_AUTH_TOKEN; resetAuthTokenCache(); - resetAuthRowCache(); try { // Set token that expires immediately (negative expiresIn) setAuthToken(token, -1); diff --git a/packages/cli/test/lib/db/project-root-cache.test.ts b/packages/cli/test/lib/db/project-root-cache.test.ts index 2f067fc11..6585d8b43 100644 --- a/packages/cli/test/lib/db/project-root-cache.test.ts +++ b/packages/cli/test/lib/db/project-root-cache.test.ts @@ -7,12 +7,14 @@ import { mkdirSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { beforeEach, describe, expect, test } from "vitest"; +import { getDatabase } from "../../../src/lib/db/index.js"; import { clearProjectRootCache, clearProjectRootCacheFor, getCachedProjectRoot, setCachedProjectRoot, } from "../../../src/lib/db/project-root-cache.js"; +import { withEnv } from "../../../src/lib/env.js"; import { cleanupTestDir, useTestConfigDir } from "../../helpers.js"; const getConfigDir = useTestConfigDir("test-project-root-cache-"); @@ -86,6 +88,26 @@ describe("getCachedProjectRoot", () => { const after = await getCachedProjectRoot(tempDir); expect(after).toBeUndefined(); }); + + test("invalidates its own cache after another invocation opens a different database", async () => { + await setCachedProjectRoot(testProjectDir, { + projectRoot: testProjectDir, + reason: "language", + }); + const futureTime = new Date(statSync(testProjectDir).mtimeMs + 5000); + utimesSync(testProjectDir, futureTime, futureTime); + + const pending = withEnv({ ...process.env }, () => + getCachedProjectRoot(testProjectDir), + ); + // The filesystem check yields while another invocation uses its store. + withEnv( + { ...process.env, SENTRY_CONFIG_DIR: join(getConfigDir(), "other") }, + getDatabase, + ); + + await expect(pending).resolves.toBeUndefined(); + }); }); describe("setCachedProjectRoot", () => { @@ -126,6 +148,30 @@ describe("setCachedProjectRoot", () => { const result = await getCachedProjectRoot("/nonexistent/path"); expect(result).toBeUndefined(); }); + + test("writes to its own cache after another invocation opens a different database", async () => { + const pending = withEnv({ ...process.env }, () => + setCachedProjectRoot(testProjectDir, { + projectRoot: testProjectDir, + reason: "vcs", + }), + ); + // The filesystem check yields while another invocation uses its store. + const otherEnv = { + ...process.env, + SENTRY_CONFIG_DIR: join(getConfigDir(), "other"), + }; + withEnv(otherEnv, getDatabase); + + await pending; + await expect(getCachedProjectRoot(testProjectDir)).resolves.toEqual({ + projectRoot: testProjectDir, + reason: "vcs", + }); + await expect( + withEnv(otherEnv, () => getCachedProjectRoot(testProjectDir)), + ).resolves.toBeUndefined(); + }); }); describe("clearProjectRootCache", () => { diff --git a/packages/cli/test/lib/index.test.ts b/packages/cli/test/lib/index.test.ts index c0b508120..e5ab06d93 100644 --- a/packages/cli/test/lib/index.test.ts +++ b/packages/cli/test/lib/index.test.ts @@ -54,6 +54,18 @@ describe("createSentrySDK() library API", () => { expect(result as string).toMatch(/\d+\.\d+\.\d+/); }); + test("bootstrap failures are SentryError and release the invocation", async () => { + const invalid = createSentrySDK({ url: "ftp://invalid.example" }); + + await expect( + invalid.api({ endpoint: "/organizations/" }), + ).rejects.toBeInstanceOf(SentryError); + expect(authorizationHeaders).toHaveLength(0); + + const valid = createSentrySDK(); + await expect(valid.run("--version")).resolves.toMatch(/\d+\.\d+\.\d+/); + }); + test("sdk.run returns parsed object for help command in JSON mode", async () => { const sdk = createSentrySDK(); const result = await sdk.run("help"); @@ -150,22 +162,25 @@ describe("createSentrySDK() library API", () => { expect(authorizationHeaders).not.toContain("Bearer stored-oauth-token"); }); - test("sdk.run returns AsyncIterable for streaming flag --follow", () => { + test("sdk.run returns AsyncIterable for streaming flag --follow", async () => { const sdk = createSentrySDK(); const result = sdk.run("log", "list", "--follow"); // Streaming flags return an AsyncIterable, not a Promise expect(Symbol.asyncIterator in (result as object)).toBe(true); + await (result as AsyncIterable)[Symbol.asyncIterator]().return?.(); }); - test("sdk.run returns AsyncIterable for streaming flag --refresh", () => { + test("sdk.run returns AsyncIterable for streaming flag --refresh", async () => { const sdk = createSentrySDK(); const result = sdk.run("issue", "list", "--refresh"); expect(Symbol.asyncIterator in (result as object)).toBe(true); + await (result as AsyncIterable)[Symbol.asyncIterator]().return?.(); }); - test("sdk.run returns AsyncIterable for streaming short flag -f", () => { + test("sdk.run returns AsyncIterable for streaming short flag -f", async () => { const sdk = createSentrySDK(); const result = sdk.run("log", "list", "-f"); expect(Symbol.asyncIterator in (result as object)).toBe(true); + await (result as AsyncIterable)[Symbol.asyncIterator]().return?.(); }); }); diff --git a/packages/cli/test/lib/issue-links.test.ts b/packages/cli/test/lib/issue-links.test.ts index 9bf8406cf..b4c422a15 100644 --- a/packages/cli/test/lib/issue-links.test.ts +++ b/packages/cli/test/lib/issue-links.test.ts @@ -25,6 +25,9 @@ import { invalidateCachedResponsesMatching } from "../../src/lib/response-cache. vi.mock("../../src/lib/api/issue-app-links.js"); vi.mock("../../src/lib/api/issue-integrations.js"); vi.mock("../../src/lib/response-cache.js"); +vi.mock("../../src/lib/db/auth.js", () => ({ + getIdentityFingerprint: () => "test-identity", +})); vi.mock("../../src/lib/region.js", () => ({ resolveOrgRegion: vi.fn().mockResolvedValue("https://de.sentry.io"), })); @@ -92,9 +95,11 @@ describe("external issue associations", () => { expect(resolveAppIssueLink).not.toHaveBeenCalled(); expect(invalidateCachedResponsesMatching).toHaveBeenCalledWith( "https://de.sentry.io/api/0/organizations/example/issues/123/", + "test-identity", ); expect(invalidateCachedResponsesMatching).toHaveBeenCalledWith( "https://sentry.io/api/0/issues/123/", + "test-identity", ); }); diff --git a/packages/cli/test/lib/response-cache.property.test.ts b/packages/cli/test/lib/response-cache.property.test.ts index 6f605c63c..b4669cc52 100644 --- a/packages/cli/test/lib/response-cache.property.test.ts +++ b/packages/cli/test/lib/response-cache.property.test.ts @@ -27,6 +27,7 @@ import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; /** Generate valid HTTP methods */ const methodArb = constantFrom("GET", "POST", "PUT", "DELETE", "PATCH"); +const TEST_IDENTITY = "identity-a"; /** Generate simple path segments */ const pathSegmentArb = string({ minLength: 1, maxLength: 20 }).filter((s) => @@ -69,7 +70,9 @@ describe("property: buildCacheKey", () => { test("produces a 64-char hex string (SHA-256)", () => { fcAssert( property(methodArb, sentryUrlArb, (method, url) => { - expect(buildCacheKey(method, url)).toMatch(/^[0-9a-f]{64}$/); + expect(buildCacheKey(method, url, TEST_IDENTITY)).toMatch( + /^[0-9a-f]{64}$/, + ); }), { numRuns: DEFAULT_NUM_RUNS }, ); @@ -78,7 +81,9 @@ describe("property: buildCacheKey", () => { test("is deterministic — same inputs produce same key", () => { fcAssert( property(methodArb, sentryUrlArb, (method, url) => { - expect(buildCacheKey(method, url)).toBe(buildCacheKey(method, url)); + expect(buildCacheKey(method, url, TEST_IDENTITY)).toBe( + buildCacheKey(method, url, TEST_IDENTITY), + ); }), { numRuns: DEFAULT_NUM_RUNS }, ); @@ -87,7 +92,9 @@ describe("property: buildCacheKey", () => { test("different methods produce different keys for same URL", () => { fcAssert( property(sentryUrlArb, (url) => { - expect(buildCacheKey("GET", url)).not.toBe(buildCacheKey("POST", url)); + expect(buildCacheKey("GET", url, TEST_IDENTITY)).not.toBe( + buildCacheKey("POST", url, TEST_IDENTITY), + ); }), { numRuns: DEFAULT_NUM_RUNS }, ); @@ -101,7 +108,9 @@ describe("property: buildCacheKey", () => { (base, path) => { const url1 = `${base}/api/0/${path}?a=1&b=2&c=3`; const url2 = `${base}/api/0/${path}?c=3&a=1&b=2`; - expect(buildCacheKey("GET", url1)).toBe(buildCacheKey("GET", url2)); + expect(buildCacheKey("GET", url1, TEST_IDENTITY)).toBe( + buildCacheKey("GET", url2, TEST_IDENTITY), + ); }, ), { numRuns: DEFAULT_NUM_RUNS }, @@ -111,7 +120,9 @@ describe("property: buildCacheKey", () => { test("method comparison is case-insensitive", () => { fcAssert( property(sentryUrlArb, (url) => { - expect(buildCacheKey("get", url)).toBe(buildCacheKey("GET", url)); + expect(buildCacheKey("get", url, TEST_IDENTITY)).toBe( + buildCacheKey("GET", url, TEST_IDENTITY), + ); }), { numRuns: DEFAULT_NUM_RUNS }, ); diff --git a/packages/cli/test/lib/response-cache.test.ts b/packages/cli/test/lib/response-cache.test.ts index 24c0a8369..f7d6f31bf 100644 --- a/packages/cli/test/lib/response-cache.test.ts +++ b/packages/cli/test/lib/response-cache.test.ts @@ -5,10 +5,10 @@ * Uses isolated temp directories per test to avoid interference. */ -import { readdir } from "node:fs/promises"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { setAuthToken } from "../../src/lib/db/auth.js"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { withEnv } from "../../src/lib/env.js"; import { buildCacheKey, clearResponseCache, @@ -62,6 +62,7 @@ function mockResponse( const TEST_URL = "https://us.sentry.io/api/0/organizations/myorg/projects/"; const TEST_METHOD = "GET"; +const TEST_IDENTITY = "identity-a"; const TEST_BODY = { data: [{ id: 1, name: "test" }] }; // --------------------------------------------------------------------------- @@ -71,9 +72,17 @@ const TEST_BODY = { data: [{ id: 1, name: "test" }] }; describe("store and retrieve", () => { test("round-trip: store then retrieve returns same body", async () => { const response = mockResponse(TEST_BODY); - await storeCachedResponse(TEST_METHOD, TEST_URL, {}, response); + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + { headers: {}, identity: TEST_IDENTITY }, + response, + ); - const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeDefined(); expect(cached!.status).toBe(200); @@ -85,9 +94,17 @@ describe("store and retrieve", () => { const linkHeader = '; rel="next"'; const response = mockResponse(TEST_BODY, 200, { link: linkHeader }); - await storeCachedResponse(TEST_METHOD, TEST_URL, {}, response); + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + { headers: {}, identity: TEST_IDENTITY }, + response, + ); - const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeDefined(); expect(cached!.headers.get("link")).toBe(linkHeader); }); @@ -96,7 +113,7 @@ describe("store and retrieve", () => { const cached = await getCachedResponse( TEST_METHOD, "https://us.sentry.io/api/0/organizations/nonexistent/projects/", - {}, + { headers: {}, identity: TEST_IDENTITY }, ); expect(cached).toBeUndefined(); }); @@ -107,11 +124,27 @@ describe("store and retrieve", () => { const body1 = { data: "org1" }; const body2 = { data: "org2" }; - await storeCachedResponse(TEST_METHOD, url1, {}, mockResponse(body1)); - await storeCachedResponse(TEST_METHOD, url2, {}, mockResponse(body2)); + await storeCachedResponse( + TEST_METHOD, + url1, + { headers: {}, identity: TEST_IDENTITY }, + mockResponse(body1), + ); + await storeCachedResponse( + TEST_METHOD, + url2, + { headers: {}, identity: TEST_IDENTITY }, + mockResponse(body2), + ); - const cached1 = await getCachedResponse(TEST_METHOD, url1, {}); - const cached2 = await getCachedResponse(TEST_METHOD, url2, {}); + const cached1 = await getCachedResponse(TEST_METHOD, url1, { + headers: {}, + identity: TEST_IDENTITY, + }); + const cached2 = await getCachedResponse(TEST_METHOD, url2, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(await cached1!.json()).toEqual(body1); expect(await cached2!.json()).toEqual(body2); @@ -121,9 +154,17 @@ describe("store and retrieve", () => { const url1 = "https://us.sentry.io/api/0/orgs/?a=1&b=2"; const url2 = "https://us.sentry.io/api/0/orgs/?b=2&a=1"; - await storeCachedResponse(TEST_METHOD, url1, {}, mockResponse(TEST_BODY)); + await storeCachedResponse( + TEST_METHOD, + url1, + { headers: {}, identity: TEST_IDENTITY }, + mockResponse(TEST_BODY), + ); - const cached = await getCachedResponse(TEST_METHOD, url2, {}); + const cached = await getCachedResponse(TEST_METHOD, url2, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeDefined(); expect(await cached!.json()).toEqual(TEST_BODY); }); @@ -135,22 +176,41 @@ describe("store and retrieve", () => { describe("method isolation", () => { test("only GET requests are cached", async () => { - await storeCachedResponse("POST", TEST_URL, {}, mockResponse(TEST_BODY)); + await storeCachedResponse( + "POST", + TEST_URL, + { headers: {}, identity: TEST_IDENTITY }, + mockResponse(TEST_BODY), + ); - const cached = await getCachedResponse("POST", TEST_URL, {}); + const cached = await getCachedResponse("POST", TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeUndefined(); }); test("GET lookup does not return POST-stored data", async () => { // This is already guaranteed since POST doesn't store, but test explicitly - await storeCachedResponse("GET", TEST_URL, {}, mockResponse(TEST_BODY)); + await storeCachedResponse( + "GET", + TEST_URL, + { headers: {}, identity: TEST_IDENTITY }, + mockResponse(TEST_BODY), + ); // GET should find it - const getResult = await getCachedResponse("GET", TEST_URL, {}); + const getResult = await getCachedResponse("GET", TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(getResult).toBeDefined(); // POST should not even look - const postResult = await getCachedResponse("POST", TEST_URL, {}); + const postResult = await getCachedResponse("POST", TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(postResult).toBeUndefined(); }); }); @@ -164,11 +224,14 @@ describe("non-2xx responses", () => { await storeCachedResponse( TEST_METHOD, TEST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse({ detail: "not found" }, 404), ); - const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeUndefined(); }); @@ -176,11 +239,14 @@ describe("non-2xx responses", () => { await storeCachedResponse( TEST_METHOD, TEST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse({ detail: "server error" }, 500), ); - const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeUndefined(); }); }); @@ -194,9 +260,17 @@ describe("Cache-Control: no-store", () => { const response = mockResponse(TEST_BODY, 200, { "cache-control": "no-store", }); - await storeCachedResponse(TEST_METHOD, TEST_URL, {}, response); + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + { headers: {}, identity: TEST_IDENTITY }, + response, + ); - const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeUndefined(); }); }); @@ -210,17 +284,42 @@ describe("clearResponseCache", () => { const url1 = "https://us.sentry.io/api/0/orgs/a/projects/"; const url2 = "https://us.sentry.io/api/0/orgs/b/projects/"; - await storeCachedResponse(TEST_METHOD, url1, {}, mockResponse({ a: 1 })); - await storeCachedResponse(TEST_METHOD, url2, {}, mockResponse({ b: 2 })); + await storeCachedResponse( + TEST_METHOD, + url1, + { headers: {}, identity: TEST_IDENTITY }, + mockResponse({ a: 1 }), + ); + await storeCachedResponse( + TEST_METHOD, + url2, + { headers: {}, identity: TEST_IDENTITY }, + mockResponse({ b: 2 }), + ); // Verify entries exist - expect(await getCachedResponse(TEST_METHOD, url1, {})).toBeDefined(); + expect( + await getCachedResponse(TEST_METHOD, url1, { + headers: {}, + identity: TEST_IDENTITY, + }), + ).toBeDefined(); await clearResponseCache(); // Verify all cleared - expect(await getCachedResponse(TEST_METHOD, url1, {})).toBeUndefined(); - expect(await getCachedResponse(TEST_METHOD, url2, {})).toBeUndefined(); + expect( + await getCachedResponse(TEST_METHOD, url1, { + headers: {}, + identity: TEST_IDENTITY, + }), + ).toBeUndefined(); + expect( + await getCachedResponse(TEST_METHOD, url2, { + headers: {}, + identity: TEST_IDENTITY, + }), + ).toBeUndefined(); }); test("is idempotent — clearing empty cache does not throw", async () => { @@ -239,13 +338,16 @@ describe("cache bypass", () => { await storeCachedResponse( TEST_METHOD, TEST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse(TEST_BODY), ); process.env.SENTRY_NO_CACHE = "1"; - const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeUndefined(); }); @@ -255,14 +357,17 @@ describe("cache bypass", () => { await storeCachedResponse( TEST_METHOD, TEST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse(TEST_BODY), ); // Remove the bypass to verify nothing was written delete process.env.SENTRY_NO_CACHE; - const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeUndefined(); }); @@ -270,13 +375,16 @@ describe("cache bypass", () => { await storeCachedResponse( TEST_METHOD, TEST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse(TEST_BODY), ); disableResponseCache(); - const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeUndefined(); }); @@ -287,14 +395,17 @@ describe("cache bypass", () => { await storeCachedResponse( TEST_METHOD, TEST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse(freshBody), ); // Re-enable cache reads to verify the write succeeded resetCacheState(); - const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeDefined(); expect(await cached!.json()).toEqual(freshBody); }); @@ -307,7 +418,7 @@ describe("cache bypass", () => { await storeCachedResponse( TEST_METHOD, TEST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse(staleBody), ); @@ -315,14 +426,17 @@ describe("cache bypass", () => { disableResponseCache(); // Verify stale entry is not served - const duringFresh = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + const duringFresh = await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(duringFresh).toBeUndefined(); // Store fresh response (overwrites the stale entry) await storeCachedResponse( TEST_METHOD, TEST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse(freshBody), ); @@ -330,7 +444,10 @@ describe("cache bypass", () => { resetCacheState(); // Verify fresh data is served from cache - const afterFresh = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + const afterFresh = await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(afterFresh).toBeDefined(); expect(await afterFresh!.json()).toEqual(freshBody); }); @@ -373,26 +490,28 @@ describe("normalizeUrl", () => { describe("buildCacheKey", () => { test("produces a 64-char hex string", () => { - expect(buildCacheKey("GET", TEST_URL)).toMatch(/^[0-9a-f]{64}$/); + expect(buildCacheKey("GET", TEST_URL, TEST_IDENTITY)).toMatch( + /^[0-9a-f]{64}$/, + ); }); test("is deterministic", () => { - expect(buildCacheKey("GET", TEST_URL)).toBe(buildCacheKey("GET", TEST_URL)); + expect(buildCacheKey("GET", TEST_URL, TEST_IDENTITY)).toBe( + buildCacheKey("GET", TEST_URL, TEST_IDENTITY), + ); }); test("different methods produce different keys", () => { - expect(buildCacheKey("GET", TEST_URL)).not.toBe( - buildCacheKey("POST", TEST_URL), + expect(buildCacheKey("GET", TEST_URL, TEST_IDENTITY)).not.toBe( + buildCacheKey("POST", TEST_URL, TEST_IDENTITY), ); }); test("different identities produce different keys for the same URL", () => { // Switching accounts must route reads/writes through a different // namespace so users never see each other's cached data. - setAuthToken("alice_access", 3600, "alice_refresh"); - const aliceKey = buildCacheKey("GET", TEST_URL); - setAuthToken("bob_access", 3600, "bob_refresh"); - const bobKey = buildCacheKey("GET", TEST_URL); + const aliceKey = buildCacheKey("GET", TEST_URL, TEST_IDENTITY); + const bobKey = buildCacheKey("GET", TEST_URL, "identity-b"); expect(aliceKey).not.toBe(bobKey); }); }); @@ -403,7 +522,10 @@ describe("buildCacheKey", () => { describe("invalid URL handling", () => { test("getCachedResponse skips cache for malformed URLs", async () => { - const result = await getCachedResponse("GET", "not-a-valid-url", {}); + const result = await getCachedResponse("GET", "not-a-valid-url", { + headers: {}, + identity: TEST_IDENTITY, + }); expect(result).toBeUndefined(); }); @@ -412,7 +534,7 @@ describe("invalid URL handling", () => { await storeCachedResponse( "GET", "not-a-valid-url", - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse({ ok: true }), ); }); @@ -429,11 +551,14 @@ describe("no-cache tier", () => { await storeCachedResponse( TEST_METHOD, autofixUrl, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse({ autofix: { status: "PROCESSING" } }), ); - const cached = await getCachedResponse(TEST_METHOD, autofixUrl, {}); + const cached = await getCachedResponse(TEST_METHOD, autofixUrl, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeUndefined(); }); @@ -443,11 +568,14 @@ describe("no-cache tier", () => { await storeCachedResponse( TEST_METHOD, rootCauseUrl, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse({ cause: "something" }), ); - const cached = await getCachedResponse(TEST_METHOD, rootCauseUrl, {}); + const cached = await getCachedResponse(TEST_METHOD, rootCauseUrl, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeUndefined(); }); }); @@ -457,11 +585,87 @@ describe("no-cache tier", () => { // --------------------------------------------------------------------------- describe("file structure", () => { + test("keeps delayed writes and cleanup scoped to their starting environment", async () => { + const firstEnv = { + ...process.env, + SENTRY_CONFIG_DIR: join(getConfigDir(), "first"), + }; + const secondEnv = { + ...firstEnv, + SENTRY_CONFIG_DIR: join(getConfigDir(), "second"), + }; + const firstCacheDir = join( + firstEnv.SENTRY_CONFIG_DIR, + "cache", + "responses", + ); + const secondCacheDir = join( + secondEnv.SENTRY_CONFIG_DIR, + "cache", + "responses", + ); + const expiredEntry = JSON.stringify({ createdAt: 0, expiresAt: 0 }); + for (const cacheDir of [firstCacheDir, secondCacheDir]) { + await mkdir(cacheDir, { recursive: true }); + await writeFile(join(cacheDir, "expired.json"), expiredEntry); + } + + const random = vi.spyOn(Math, "random").mockReturnValue(0); + try { + const identity = TEST_IDENTITY; + const key = buildCacheKey(TEST_METHOD, TEST_URL, identity); + const body = new TransformStream(); + const writer = body.writable.getWriter(); + const headers = { authorization: "Bearer synthetic-token-a" }; + const pendingWrite = withEnv(firstEnv, () => + storeCachedResponse( + TEST_METHOD, + TEST_URL, + { headers, identity }, + new Response(body.readable, { + headers: { + "content-type": "application/json", + "cache-control": "private, max-age=300", + vary: "Authorization", + }, + }), + ), + ); + + await withEnv(secondEnv, async () => { + await writer.write(new TextEncoder().encode(JSON.stringify(TEST_BODY))); + await writer.close(); + await pendingWrite; + + const entry = JSON.parse( + await readFile(join(firstCacheDir, `${key}.json`), "utf-8"), + ); + expect(entry).toMatchObject({ identity, body: TEST_BODY }); + await vi.waitFor(async () => { + expect(await readdir(firstCacheDir)).toEqual([`${key}.json`]); + }); + expect(await readdir(secondCacheDir)).toEqual(["expired.json"]); + expect( + await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: "identity-b", + }), + ).toBeUndefined(); + }); + const cached = await withEnv(firstEnv, () => + getCachedResponse(TEST_METHOD, TEST_URL, { headers, identity }), + ); + expect(await cached?.json()).toEqual(TEST_BODY); + } finally { + random.mockRestore(); + } + }); + test("creates cache directory under config dir", async () => { await storeCachedResponse( TEST_METHOD, TEST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse(TEST_BODY), ); @@ -475,7 +679,7 @@ describe("file structure", () => { await storeCachedResponse( TEST_METHOD, TEST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse(TEST_BODY), ); @@ -504,18 +708,21 @@ describe("atomic write regression", () => { await storeCachedResponse( TEST_METHOD, TEST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse({ data: `stale-${i}` }), ); const freshBody = { data: `fresh-${i}` }; await storeCachedResponse( TEST_METHOD, TEST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse(freshBody), ); - const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }); expect(cached).toBeDefined(); expect(await cached!.json()).toEqual(freshBody); } @@ -536,24 +743,29 @@ describe("invalidateCachedResponsesMatching", () => { await storeCachedResponse( "GET", ORG_LIST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse({ matched: true }), ); await storeCachedResponse( "GET", `${OTHER_PREFIX}?cursor=def`, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse({ matched: false }), ); - await invalidateCachedResponsesMatching(ORG_PREFIX); + await invalidateCachedResponsesMatching(ORG_PREFIX, TEST_IDENTITY); // The matching entry is gone; the other org's entry survives. - expect(await getCachedResponse("GET", ORG_LIST_URL, {})).toBeUndefined(); + expect( + await getCachedResponse("GET", ORG_LIST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }), + ).toBeUndefined(); const survivor = await getCachedResponse( "GET", `${OTHER_PREFIX}?cursor=def`, - {}, + { headers: {}, identity: TEST_IDENTITY }, ); expect(survivor).toBeDefined(); }); @@ -561,24 +773,31 @@ describe("invalidateCachedResponsesMatching", () => { test("does not delete entries belonging to a different identity", async () => { // A writes a cache entry, B sweeps the same URL prefix; A's entry // must survive because B can only see its own identity's files. - setAuthToken("identity-a", 3600, "refresh-a"); await storeCachedResponse( "GET", ORG_LIST_URL, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse({ owner: "a" }), ); - expect(await getCachedResponse("GET", ORG_LIST_URL, {})).toBeDefined(); + expect( + await getCachedResponse("GET", ORG_LIST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }), + ).toBeDefined(); - setAuthToken("identity-b", 3600, "refresh-b"); - await invalidateCachedResponsesMatching(ORG_PREFIX); + await invalidateCachedResponsesMatching(ORG_PREFIX, "identity-b"); - setAuthToken("identity-a", 3600, "refresh-a"); - expect(await getCachedResponse("GET", ORG_LIST_URL, {})).toBeDefined(); + expect( + await getCachedResponse("GET", ORG_LIST_URL, { + headers: {}, + identity: TEST_IDENTITY, + }), + ).toBeDefined(); }); test("is a no-op when the cache dir does not exist", async () => { - await invalidateCachedResponsesMatching(ORG_PREFIX); + await invalidateCachedResponsesMatching(ORG_PREFIX, TEST_IDENTITY); }); }); @@ -597,25 +816,25 @@ describe("invalidateCachedResponsesMatching with query params", () => { await storeCachedResponse( "GET", `${DETAIL_BASE}?collapse=stats&collapse=lifetime`, - {}, + { headers: {}, identity: TEST_IDENTITY }, mockResponse({ id: "12345" }), ); expect( await getCachedResponse( "GET", `${DETAIL_BASE}?collapse=stats&collapse=lifetime`, - {}, + { headers: {}, identity: TEST_IDENTITY }, ), ).toBeDefined(); // Mutation-side invalidator uses the base URL (no params). - await invalidateCachedResponsesMatching(DETAIL_BASE); + await invalidateCachedResponsesMatching(DETAIL_BASE, TEST_IDENTITY); expect( await getCachedResponse( "GET", `${DETAIL_BASE}?collapse=stats&collapse=lifetime`, - {}, + { headers: {}, identity: TEST_IDENTITY }, ), ).toBeUndefined(); }); diff --git a/packages/cli/test/lib/sdk-invoke-isolation.focused.test.ts b/packages/cli/test/lib/sdk-invoke-isolation.focused.test.ts index 744c1a73c..bec6e7811 100644 --- a/packages/cli/test/lib/sdk-invoke-isolation.focused.test.ts +++ b/packages/cli/test/lib/sdk-invoke-isolation.focused.test.ts @@ -47,13 +47,14 @@ vi.mock("@sentry/node-core/light", () => ({ getClient: () => null })); import { getCustomHeaders } from "../../src/lib/custom-headers.js"; import { getEnv } from "../../src/lib/env.js"; import { buildInvoker } from "../../src/lib/sdk-invoke.js"; +import { SentryError } from "../../src/lib/sdk-types.js"; import { useTestConfigDir } from "../helpers.js"; useTestConfigDir("sdk-invoke-isolation-focused-"); type InvocationResult = { header?: string; host?: string; token?: string }; -describe("overlapping SDK invocation isolation", () => { +describe("SDK invocation isolation", () => { beforeEach(() => { invocationState.handler = undefined; }); @@ -61,7 +62,7 @@ describe("overlapping SDK invocation isolation", () => { invocationState.handler = undefined; }); - test("keeps environment and structured headers invocation-local", async () => { + test("rejects overlap and keeps environment and structured headers invocation-local", async () => { const started = new Map(); const release = new Map(); for (const id of ["first", "second"]) { @@ -81,29 +82,49 @@ describe("overlapping SDK invocation isolation", () => { } satisfies InvocationResult); }; + const invokeSecond = buildInvoker({ + token: "token-second", + url: "https://second.example.com", + headers: { "X-Invocation": "second" }, + }); const first = buildInvoker({ token: "token-first", url: "https://first.example.com", headers: { "X-Invocation": "first" }, })(["focused", "probe"], { id: "first" }, []) as Promise; - await started.get("first")?.promise; - const second = buildInvoker({ - token: "token-second", - url: "https://second.example.com", - headers: { "X-Invocation": "second" }, - })(["focused", "probe"], { id: "second" }, []) as Promise; - await started.get("second")?.promise; - release.get("first")?.resolve(); - expect(await first).toEqual({ - header: "first", - host: "https://first.example.com", - token: "token-first", - }); - release.get("second")?.resolve(); - expect(await second).toEqual({ - header: "second", - host: "https://second.example.com", - token: "token-second", - }); + try { + await started.get("first")?.promise; + // Production output and telemetry require sequential calls, even though + // this focused test replaces telemetry with a stateless mock. + release.get("second")?.resolve(); + const overlapping = invokeSecond( + ["focused", "probe"], + { id: "second" }, + [], + ); + await expect(overlapping).rejects.toBeInstanceOf(SentryError); + await expect(overlapping).rejects.toThrow( + "Concurrent SDK calls are not supported", + ); + + release.get("first")?.resolve(); + expect(await first).toEqual({ + header: "first", + host: "https://first.example.com", + token: "token-first", + }); + expect( + await invokeSecond(["focused", "probe"], { id: "second" }, []), + ).toEqual({ + header: "second", + host: "https://second.example.com", + token: "token-second", + }); + } finally { + for (const barrier of release.values()) { + barrier.resolve(); + } + await Promise.allSettled([first]); + } }); }); diff --git a/packages/cli/test/lib/sdk-isolation.test.ts b/packages/cli/test/lib/sdk-isolation.test.ts new file mode 100644 index 000000000..e25a71659 --- /dev/null +++ b/packages/cli/test/lib/sdk-isolation.test.ts @@ -0,0 +1,803 @@ +/** Public SDK regressions for auth, cache, and host isolation between calls. */ + +import { mkdir, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { setImmediate } from "node:timers/promises"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import createSentrySDK from "../../src/index.js"; +import { getAuthConfig, setAuthToken } from "../../src/lib/db/auth.js"; +import { setEnv } from "../../src/lib/env.js"; +import { resetCacheState } from "../../src/lib/response-cache.js"; +import { resetAuthenticatedFetch } from "../../src/lib/sentry-client.js"; +import { + mockFetch, + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../helpers.js"; + +const ENDPOINT = "/organizations/synthetic-org/projects/"; +const FIRST_TOKEN = "synthetic-token-A"; +const SECOND_TOKEN = "synthetic-token-B"; +const MALFORMED_TOKEN = "synthetic\nbad-token"; +const FIRST_PROJECT = { + id: "1", + slug: "project-a", + name: "Project A", + platform: "javascript", +}; +const SECOND_PROJECT = { ...FIRST_PROJECT, id: "2", slug: "project-b" }; + +function deferred() { + let resolve = () => { + // Replaced synchronously by the promise executor before this helper returns. + }; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("SDK invocation isolation", () => { + const getConfigDir = useTestConfigDir("sdk-isolation-", { + isolateProjectRoot: true, + }); + useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_CUSTOM_HEADERS", + "SENTRY_NO_CACHE", + ]); + + let originalFetch: typeof globalThis.fetch; + let requests: Request[]; + let cacheControl: string; + let waitForFirstRequest: Promise | undefined; + + async function countCacheEntries(configDir = getConfigDir()) { + const entries = await readdir(join(configDir, "cache", "responses")); + return entries.filter((entry) => entry.endsWith(".json")).length; + } + + beforeEach(async () => { + await resetHostScopingState(); + resetAuthenticatedFetch(); + resetCacheState(); + originalFetch = globalThis.fetch; + requests = []; + cacheControl = "no-store"; + waitForFirstRequest = undefined; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + requests.push(request); + if (requests.length === 1) { + await waitForFirstRequest; + } + const project = + request.headers.get("Authorization") === `Bearer ${FIRST_TOKEN}` + ? FIRST_PROJECT + : SECOND_PROJECT; + return new Response(JSON.stringify([project]), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": cacheControl, + Vary: "Authorization", + }, + }); + }); + }); + + afterEach(async () => { + setEnv(process.env); + globalThis.fetch = originalFetch; + resetAuthenticatedFetch(); + resetCacheState(); + await resetHostScopingState(); + }); + + test.each(["typed", "run"] as const)( + "%s calls preserve each client's warm cache and reject a malformed token", + async (entryPoint) => { + cacheControl = "private, max-age=300"; + const options = { cwd: getConfigDir() }; + const first = createSentrySDK({ ...options, token: FIRST_TOKEN }); + const second = createSentrySDK({ ...options, token: SECOND_TOKEN }); + const malformed = createSentrySDK({ ...options, token: MALFORMED_TOKEN }); + const envBefore = { ...process.env }; + const invoke = (sdk: ReturnType) => + entryPoint === "typed" + ? sdk.api({ endpoint: ENDPOINT }) + : sdk.run("api", ENDPOINT); + await expect(invoke(first)).resolves.toMatchObject({ + body: [FIRST_PROJECT], + }); + await expect.poll(() => countCacheEntries()).toBe(1); + await expect(invoke(second)).resolves.toMatchObject({ + body: [SECOND_PROJECT], + }); + await expect.poll(() => countCacheEntries()).toBe(2); + await expect(malformed.api({ endpoint: ENDPOINT })).rejects.toThrow( + "Invalid authentication token", + ); + await expect(invoke(first)).resolves.toMatchObject({ + body: [FIRST_PROJECT], + }); + + expect( + requests.map((request) => request.headers.get("Authorization")), + ).toEqual([`Bearer ${FIRST_TOKEN}`, `Bearer ${SECOND_TOKEN}`]); + expect(process.env).toEqual(envBefore); + }, + ); + + test("uncached calls use the current token and reject malformed credentials", async () => { + const options = { cwd: getConfigDir() }; + const first = createSentrySDK({ ...options, token: FIRST_TOKEN }); + const second = createSentrySDK({ ...options, token: SECOND_TOKEN }); + const malformed = createSentrySDK({ ...options, token: MALFORMED_TOKEN }); + + await expect(first.api({ endpoint: ENDPOINT })).resolves.toMatchObject({ + body: [FIRST_PROJECT], + }); + await expect(second.api({ endpoint: ENDPOINT })).resolves.toMatchObject({ + body: [SECOND_PROJECT], + }); + await expect(malformed.api({ endpoint: ENDPOINT })).rejects.toThrow( + "Invalid authentication token", + ); + expect( + requests.map((request) => request.headers.get("Authorization")), + ).toEqual([`Bearer ${FIRST_TOKEN}`, `Bearer ${SECOND_TOKEN}`]); + }); + + test("stored credentials follow the current config directory", async () => { + const firstDir = join(getConfigDir(), "first"); + const secondDir = join(getConfigDir(), "second"); + await mkdir(firstDir); + await mkdir(secondDir); + process.env.SENTRY_CONFIG_DIR = firstDir; + setAuthToken(FIRST_TOKEN, 3600, "synthetic-refresh-A"); + process.env.SENTRY_CONFIG_DIR = secondDir; + setAuthToken(SECOND_TOKEN, 3600, "synthetic-refresh-B"); + + const sdk = createSentrySDK({ cwd: getConfigDir() }); + process.env.SENTRY_CONFIG_DIR = firstDir; + await expect(sdk.api({ endpoint: ENDPOINT })).resolves.toMatchObject({ + body: [FIRST_PROJECT], + }); + process.env.SENTRY_CONFIG_DIR = secondDir; + await expect(sdk.api({ endpoint: ENDPOINT })).resolves.toMatchObject({ + body: [SECOND_PROJECT], + }); + process.env.SENTRY_CONFIG_DIR = firstDir; + await expect(sdk.api({ endpoint: ENDPOINT })).resolves.toMatchObject({ + body: [FIRST_PROJECT], + }); + expect( + requests.map((request) => request.headers.get("Authorization")), + ).toEqual([ + `Bearer ${FIRST_TOKEN}`, + `Bearer ${SECOND_TOKEN}`, + `Bearer ${FIRST_TOKEN}`, + ]); + }); + + test("captures the config directory before the caller restores its environment", async () => { + const firstDir = join(getConfigDir(), "first"); + const secondDir = join(getConfigDir(), "second"); + await mkdir(firstDir); + await mkdir(secondDir); + process.env.SENTRY_CONFIG_DIR = firstDir; + setAuthToken(FIRST_TOKEN, 3600, "synthetic-refresh-A"); + process.env.SENTRY_CONFIG_DIR = secondDir; + setAuthToken(SECOND_TOKEN, 3600, "synthetic-refresh-B"); + const sdk = createSentrySDK({ cwd: getConfigDir() }); + + process.env.SENTRY_CONFIG_DIR = firstDir; + const pending = sdk.api({ endpoint: ENDPOINT }); + process.env.SENTRY_CONFIG_DIR = secondDir; + + await expect(pending).resolves.toMatchObject({ body: [FIRST_PROJECT] }); + expect(requests[0]?.headers.get("Authorization")).toBe( + `Bearer ${FIRST_TOKEN}`, + ); + expect(process.env.SENTRY_CONFIG_DIR).toBe(secondDir); + }); + + test.each(["shared", "separate"] as const)( + "a response pending after a command error keeps its identity with %s config directories", + async (directories) => { + const firstDir = getConfigDir(); + const secondDir = + directories === "shared" ? firstDir : join(firstDir, "second"); + if (directories === "separate") { + await mkdir(secondDir); + } + const host = "https://synthetic.example.invalid"; + const projectEndpoint = "/projects/synthetic-org/synthetic-project/"; + const keysEndpoint = `${projectEndpoint}keys/`; + const keysStarted = deferred(); + const releaseKeys = deferred(); + const secondStarted = deferred(); + const releaseSecond = deferred(); + const firstKeys = [ + { + id: "1", + isActive: true, + dsn: { public: "https://public@ingest.example.invalid/1" }, + }, + ]; + const secondKeys = [ + { + id: "2", + isActive: true, + dsn: { public: "https://public@ingest.example.invalid/2" }, + }, + ]; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + requests.push(request); + const pathname = new URL(request.url).pathname; + if (pathname === "/api/0/organizations/synthetic-org/") { + return Response.json( + { id: "1", slug: "synthetic-org", links: { regionUrl: host } }, + { headers: { "Cache-Control": "no-store" } }, + ); + } + if (pathname === `/api/0${projectEndpoint}`) { + await keysStarted.promise; + return Response.json( + { detail: "Not found" }, + { status: 404, headers: { "Cache-Control": "no-store" } }, + ); + } + if (pathname === `/api/0${keysEndpoint}`) { + const isFirst = + request.headers.get("Authorization") === `Bearer ${FIRST_TOKEN}`; + if (isFirst) { + keysStarted.resolve(); + await releaseKeys.promise; + } + return Response.json(isFirst ? firstKeys : secondKeys, { + headers: { + "Cache-Control": "private, max-age=300", + Vary: "Authorization", + }, + }); + } + if (pathname === "/api/0/hold/") { + secondStarted.resolve(); + await releaseSecond.promise; + return Response.json( + { ok: true }, + { headers: { "Cache-Control": "no-store" } }, + ); + } + throw new Error(`Unexpected request: ${pathname}`); + }); + const options = { cwd: getConfigDir(), url: host }; + const first = createSentrySDK({ ...options, token: FIRST_TOKEN }); + const second = createSentrySDK({ ...options, token: SECOND_TOKEN }); + let pendingSecond: Promise | undefined; + try { + // project.view starts both requests; its 404 leaves /keys/ pending. + await expect( + first.project.view({ orgProject: "synthetic-org/synthetic-project" }), + ).rejects.toThrow(); + process.env.SENTRY_CONFIG_DIR = secondDir; + pendingSecond = second.api({ endpoint: "/hold/" }); + await secondStarted.promise; + releaseKeys.resolve(); + await expect.poll(() => countCacheEntries(firstDir)).toBe(1); + } finally { + releaseKeys.resolve(); + releaseSecond.resolve(); + await pendingSecond; + } + + // A fresh SDK instance must not read A's persisted response as B. + const freshSecond = createSentrySDK({ ...options, token: SECOND_TOKEN }); + await expect( + freshSecond.api({ endpoint: keysEndpoint }), + ).resolves.toMatchObject({ body: secondKeys }); + await expect + .poll(() => countCacheEntries(secondDir)) + .toBe(directories === "shared" ? 2 : 1); + process.env.SENTRY_CONFIG_DIR = firstDir; + await expect( + first.api({ endpoint: keysEndpoint }), + ).resolves.toMatchObject({ + body: firstKeys, + }); + expect( + requests + .filter((request) => new URL(request.url).pathname.endsWith("/keys/")) + .map((request) => request.headers.get("Authorization")), + ).toEqual([`Bearer ${FIRST_TOKEN}`, `Bearer ${SECOND_TOKEN}`]); + }, + ); + + test("a late 401 refreshes the original client's credentials while another client runs", async () => { + const firstDir = getConfigDir(); + const secondDir = join(firstDir, "second"); + const firstHost = "https://first.example.invalid"; + const secondHost = "https://second.example.invalid"; + const refreshedToken = "synthetic-refreshed-A"; + await mkdir(secondDir); + setAuthToken(FIRST_TOKEN, 3600, "synthetic-refresh-A", { host: firstHost }); + process.env.SENTRY_CONFIG_DIR = secondDir; + setAuthToken(SECOND_TOKEN, 3600, "synthetic-refresh-B", { + host: secondHost, + }); + const keysStarted = deferred(); + const releaseKeys = deferred(); + const secondStarted = deferred(); + const releaseSecond = deferred(); + const refreshRequests: { url: string; refreshToken: string | null }[] = []; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + requests.push(request); + const url = new URL(request.url); + const authorization = request.headers.get("Authorization"); + const headers = { "Cache-Control": "no-store" }; + if (url.pathname === "/api/0/organizations/synthetic-org/") { + return Response.json( + { id: "1", slug: "synthetic-org", links: { regionUrl: url.origin } }, + { headers }, + ); + } + if (url.pathname === "/api/0/projects/synthetic-org/synthetic-project/") { + await keysStarted.promise; + return Response.json({ detail: "Not found" }, { status: 404, headers }); + } + if (url.pathname.endsWith("/keys/")) { + if (authorization === `Bearer ${FIRST_TOKEN}`) { + keysStarted.resolve(); + await releaseKeys.promise; + return Response.json({ detail: "Expired" }, { status: 401, headers }); + } + return Response.json([], { headers }); + } + if (url.pathname === "/oauth/token/") { + refreshRequests.push({ + url: request.url, + refreshToken: new URLSearchParams(await request.text()).get( + "refresh_token", + ), + }); + return Response.json({ + access_token: refreshedToken, + refresh_token: "synthetic-rotated-refresh-A", + token_type: "bearer", + expires_in: 3600, + }); + } + if (url.pathname === "/api/0/hold/") { + secondStarted.resolve(); + await releaseSecond.promise; + } + return Response.json({ authorization }, { headers }); + }); + const first = createSentrySDK({ cwd: firstDir, url: firstHost }); + const second = createSentrySDK({ cwd: firstDir, url: secondHost }); + let pendingSecond: Promise | undefined; + try { + process.env.SENTRY_CONFIG_DIR = firstDir; + await expect( + first.project.view({ orgProject: "synthetic-org/synthetic-project" }), + ).rejects.toThrow(); + process.env.SENTRY_CONFIG_DIR = secondDir; + pendingSecond = second.api({ endpoint: "/hold/" }); + await secondStarted.promise; + releaseKeys.resolve(); + await vi.waitFor( + () => { + expect( + requests.find( + (request) => + request.url.endsWith("/keys/") && + request.headers.get("Authorization") === + `Bearer ${refreshedToken}`, + )?.url, + ).toBe( + `${firstHost}/api/0/projects/synthetic-org/synthetic-project/keys/`, + ); + }, + { timeout: 3000 }, + ); + } finally { + releaseKeys.resolve(); + releaseSecond.resolve(); + await pendingSecond; + } + + expect(refreshRequests).toEqual([ + { url: `${firstHost}/oauth/token/`, refreshToken: "synthetic-refresh-A" }, + ]); + expect(getAuthConfig()).toMatchObject({ + token: SECOND_TOKEN, + refreshToken: "synthetic-refresh-B", + }); + await expect(second.api({ endpoint: "/identity/" })).resolves.toMatchObject( + { + body: { authorization: `Bearer ${SECOND_TOKEN}` }, + }, + ); + process.env.SENTRY_CONFIG_DIR = firstDir; + expect(getAuthConfig()).toMatchObject({ + token: refreshedToken, + refreshToken: "synthetic-rotated-refresh-A", + }); + }); + + test("a fresh invocation leaves its cached responses usable by the next invocation", async () => { + globalThis.fetch = mockFetch((input, init) => { + const request = new Request(input, init); + requests.push(request); + const url = new URL(request.url); + const body = url.pathname.endsWith("/teams/") + ? [{ id: "1", slug: "synthetic-team", name: "Synthetic Team" }] + : { + id: "1", + slug: "synthetic-org", + links: { regionUrl: url.origin }, + }; + return Promise.resolve( + Response.json(body, { + headers: { + "Cache-Control": "private, max-age=300", + Vary: "Authorization", + }, + }), + ); + }); + const sdk = createSentrySDK({ cwd: getConfigDir(), token: FIRST_TOKEN }); + + const result = await sdk.team.list({ + orgProject: "synthetic-org/", + fresh: true, + }); + await expect.poll(() => countCacheEntries()).toBe(2); + await expect( + sdk.team.list({ orgProject: "synthetic-org/" }), + ).resolves.toEqual(result); + expect(requests).toHaveLength(2); + }); + + test.each(["during", "after"] as const)( + "shared credentials handle a second 401 %s an OAuth refresh", + async (timing) => { + const host = "https://synthetic.example.invalid"; + const refreshedToken = "synthetic-refreshed-A"; + setAuthToken(FIRST_TOKEN, 3600, "synthetic-refresh-A", { host }); + const keysStarted = deferred(); + const releaseKeys = deferred(); + const refreshStarted = deferred(); + const releaseRefresh = deferred(); + const releaseDuplicateRefresh = deferred(); + const secondStarted = deferred(); + const releaseSecond401 = deferred(); + const refreshTokens: (string | null)[] = []; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + requests.push(request); + const pathname = new URL(request.url).pathname; + const authorization = request.headers.get("Authorization"); + const headers = { "Cache-Control": "no-store" }; + if (pathname === "/api/0/organizations/synthetic-org/") { + return Response.json( + { id: "1", slug: "synthetic-org", links: { regionUrl: host } }, + { headers }, + ); + } + if (pathname === "/api/0/projects/synthetic-org/synthetic-project/") { + await keysStarted.promise; + return Response.json( + { detail: "Not found" }, + { status: 404, headers }, + ); + } + if (pathname.endsWith("/keys/")) { + if (authorization === `Bearer ${FIRST_TOKEN}`) { + keysStarted.resolve(); + await releaseKeys.promise; + return Response.json( + { detail: "Expired" }, + { status: 401, headers }, + ); + } + return Response.json([], { headers }); + } + if (pathname === "/api/0/next/") { + if (authorization === `Bearer ${FIRST_TOKEN}`) { + secondStarted.resolve(); + await releaseSecond401.promise; + return Response.json( + { detail: "Expired" }, + { status: 401, headers }, + ); + } + return Response.json( + { authorization }, + { + headers: { + "Cache-Control": "private, max-age=300", + Vary: "Authorization", + }, + }, + ); + } + if (pathname === "/oauth/token/") { + const refreshToken = new URLSearchParams(await request.text()).get( + "refresh_token", + ); + refreshTokens.push(refreshToken); + const isFirstRefresh = refreshTokens.length === 1; + if (isFirstRefresh) { + refreshStarted.resolve(); + await releaseRefresh.promise; + } + if ( + isFirstRefresh || + refreshToken === "synthetic-rotated-refresh-A" + ) { + return Response.json({ + access_token: refreshedToken, + refresh_token: "synthetic-rotated-refresh-A", + token_type: "bearer", + expires_in: 3600, + }); + } + // Reusing a refresh token after rotation invalidates the second refresh. + await releaseDuplicateRefresh.promise; + return Response.json({ error: "invalid_grant" }, { status: 400 }); + } + throw new Error(`Unexpected request: ${pathname}`); + }); + const options = { cwd: getConfigDir(), url: host }; + const first = createSentrySDK(options); + const second = createSentrySDK(options); + let pendingSecond: Promise | undefined; + try { + await expect( + first.project.view({ orgProject: "synthetic-org/synthetic-project" }), + ).rejects.toThrow(); + releaseKeys.resolve(); + await refreshStarted.promise; + pendingSecond = second.api({ endpoint: "/next/" }); + await secondStarted.promise; + if (timing === "during") { + releaseSecond401.resolve(); + // Let B handle its 401 while A's refresh remains in flight. + await setImmediate(); + } + releaseRefresh.resolve(); + await expect.poll(() => getAuthConfig()?.token).toBe(refreshedToken); + releaseSecond401.resolve(); + releaseDuplicateRefresh.resolve(); + await expect(pendingSecond).resolves.toMatchObject({ + body: { authorization: `Bearer ${refreshedToken}` }, + }); + await expect.poll(() => countCacheEntries()).toBe(1); + await expect( + createSentrySDK(options).api({ endpoint: "/next/" }), + ).resolves.toMatchObject({ + body: { authorization: `Bearer ${refreshedToken}` }, + }); + // The in-flight request kept its original credential namespace. A new + // invocation warms the rotated identity's namespace, then reuses it. + await expect.poll(() => countCacheEntries()).toBe(2); + await expect( + createSentrySDK(options).api({ endpoint: "/next/" }), + ).resolves.toMatchObject({ + body: { authorization: `Bearer ${refreshedToken}` }, + }); + if (timing === "during") { + expect(refreshTokens).toEqual(["synthetic-refresh-A"]); + } else { + expect(refreshTokens).toEqual([ + "synthetic-refresh-A", + "synthetic-rotated-refresh-A", + ]); + } + expect( + requests.filter((request) => request.url.endsWith("/next/")), + ).toHaveLength(3); + expect(getAuthConfig()).toMatchObject({ + token: refreshedToken, + refreshToken: "synthetic-rotated-refresh-A", + }); + } finally { + releaseKeys.resolve(); + releaseRefresh.resolve(); + releaseDuplicateRefresh.resolve(); + releaseSecond401.resolve(); + if (pendingSecond) { + await Promise.allSettled([pendingSecond]); + } + await vi.waitFor( + () => { + expect( + requests.filter((request) => request.url.endsWith("/keys/")), + ).toHaveLength(2); + }, + { timeout: 3000 }, + ); + } + }, + ); + + test("each client scopes credentials and custom headers to its own host", async () => { + const first = createSentrySDK({ + token: FIRST_TOKEN, + url: "https://first.example.invalid", + headers: { "X-Synthetic-Proxy": "first" }, + cwd: getConfigDir(), + }); + const second = createSentrySDK({ + token: SECOND_TOKEN, + url: "https://second.example.invalid", + cwd: getConfigDir(), + }); + + await first.api({ endpoint: ENDPOINT }); + await second.api({ endpoint: ENDPOINT }); + await first.api({ endpoint: ENDPOINT }); + + expect( + requests.map((request) => ({ + origin: new URL(request.url).origin, + authorization: request.headers.get("Authorization"), + proxy: request.headers.get("X-Synthetic-Proxy"), + })), + ).toEqual([ + { + origin: "https://first.example.invalid", + authorization: `Bearer ${FIRST_TOKEN}`, + proxy: "first", + }, + { + origin: "https://second.example.invalid", + authorization: `Bearer ${SECOND_TOKEN}`, + proxy: null, + }, + { + origin: "https://first.example.invalid", + authorization: `Bearer ${FIRST_TOKEN}`, + proxy: "first", + }, + ]); + }); + + test("org-scoped commands resolve the same org independently across hosts and config directories", async () => { + const firstDir = join(getConfigDir(), "first"); + const secondDir = join(getConfigDir(), "second"); + await mkdir(firstDir); + await mkdir(secondDir); + globalThis.fetch = mockFetch((input, init) => { + const request = new Request(input, init); + requests.push(request); + const url = new URL(request.url); + const body = url.pathname.endsWith("/teams/") + ? [{ id: "1", slug: "synthetic-team", name: "Synthetic Team" }] + : { + id: "1", + slug: "synthetic-org", + name: "Synthetic Org", + links: { regionUrl: url.origin }, + }; + return Promise.resolve( + new Response(JSON.stringify(body), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }), + ); + }); + const first = createSentrySDK({ + token: FIRST_TOKEN, + url: "https://first.example.invalid", + cwd: getConfigDir(), + }); + const second = createSentrySDK({ + token: SECOND_TOKEN, + url: "https://second.example.invalid", + cwd: getConfigDir(), + }); + + process.env.SENTRY_CONFIG_DIR = firstDir; + await first.team.list({ orgProject: "synthetic-org/" }); + process.env.SENTRY_CONFIG_DIR = secondDir; + await second.team.list({ orgProject: "synthetic-org/" }); + + expect( + requests.map((request) => ({ + url: `${new URL(request.url).origin}${new URL(request.url).pathname}`, + authorization: request.headers.get("Authorization"), + })), + ).toEqual([ + { + url: "https://first.example.invalid/api/0/organizations/synthetic-org/", + authorization: `Bearer ${FIRST_TOKEN}`, + }, + { + url: "https://first.example.invalid/api/0/organizations/synthetic-org/teams/", + authorization: `Bearer ${FIRST_TOKEN}`, + }, + { + url: "https://second.example.invalid/api/0/organizations/synthetic-org/", + authorization: `Bearer ${SECOND_TOKEN}`, + }, + { + url: "https://second.example.invalid/api/0/organizations/synthetic-org/teams/", + authorization: `Bearer ${SECOND_TOKEN}`, + }, + ]); + }); + + test.each(["setup", "command"] as const)( + "a %s error leaves the next invocation usable", + async (failure) => { + const options = { cwd: getConfigDir(), token: FIRST_TOKEN }; + const failing = createSentrySDK({ + ...options, + ...(failure === "setup" + ? { headers: { Authorization: "invalid" } } + : {}), + }); + const envBefore = { ...process.env }; + + await expect( + failure === "setup" + ? failing.api({ endpoint: ENDPOINT }) + : failing.api({ endpoint: "../invalid" }), + ).rejects.toThrow(); + const second = createSentrySDK({ + cwd: getConfigDir(), + token: SECOND_TOKEN, + }); + await expect(second.api({ endpoint: ENDPOINT })).resolves.toMatchObject({ + body: [SECOND_PROJECT], + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.headers.get("Authorization")).toBe( + `Bearer ${SECOND_TOKEN}`, + ); + expect(process.env).toEqual(envBefore); + }, + ); + + test("rejects an overlapping invocation without disturbing the active client", async () => { + let releaseFirstRequest: (() => void) | undefined; + waitForFirstRequest = new Promise((resolve) => { + releaseFirstRequest = resolve; + }); + const first = createSentrySDK({ token: FIRST_TOKEN, cwd: getConfigDir() }); + const second = createSentrySDK({ + token: SECOND_TOKEN, + cwd: getConfigDir(), + }); + const pendingFirst = first.api({ endpoint: ENDPOINT }); + try { + await vi.waitFor(() => expect(requests).toHaveLength(1)); + await expect(second.api({ endpoint: ENDPOINT })).rejects.toThrow( + /concurrent|active|overlap/i, + ); + expect(requests).toHaveLength(1); + } finally { + releaseFirstRequest?.(); + await pendingFirst; + } + await expect(second.api({ endpoint: ENDPOINT })).resolves.toMatchObject({ + body: [SECOND_PROJECT], + }); + expect( + requests.map((request) => request.headers.get("Authorization")), + ).toEqual([`Bearer ${FIRST_TOKEN}`, `Bearer ${SECOND_TOKEN}`]); + }); +}); diff --git a/packages/cli/test/lib/sdk-stream-isolation.test.ts b/packages/cli/test/lib/sdk-stream-isolation.test.ts new file mode 100644 index 000000000..4e162d8a6 --- /dev/null +++ b/packages/cli/test/lib/sdk-stream-isolation.test.ts @@ -0,0 +1,268 @@ +/** Public SDK streaming lifecycle regressions with real commands and mocked HTTP. */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import createSentrySDK, { + SentryError, + type SentryOptions, +} from "../../src/index.js"; +import type { TraceLog } from "../../src/types/sentry.js"; +import { mockFetch, useEnvSandbox, useTestConfigDir } from "../helpers.js"; + +const HOST = "https://synthetic.example.invalid"; +const ORG = "synthetic-org"; +const TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; +const ENDPOINT = `/organizations/${ORG}/projects/`; +const FIRST_TOKEN = "synthetic-stream-token-a"; +const SECOND_TOKEN = "synthetic-stream-token-b"; +const TRACE_LOG: TraceLog = { + id: "log-1", + "project.id": 1, + trace: TRACE_ID, + severity_number: 9, + severity: "info", + timestamp: "2025-01-30T14:32:15+00:00", + timestamp_precise: 1_738_247_535_123_456_000, + message: "Request received", +}; + +/** Allow a test to hold an HTTP response across an iterator return. */ +function deferred() { + let resolve = () => { + // Replaced synchronously by the promise executor before this helper returns. + }; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("SDK streaming invocation isolation", () => { + const getConfigDir = useTestConfigDir("sdk-stream-isolation-", { + isolateProjectRoot: true, + }); + useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_CUSTOM_HEADERS", + "SENTRY_NO_CACHE", + ]); + + let originalFetch: typeof globalThis.fetch; + let requests: Request[]; + let traceStatus: number; + let traceResponses: number; + let regionGate: ReturnType | undefined; + let dashboardGate: ReturnType | undefined; + let iterators: AsyncIterator[]; + + beforeEach(() => { + originalFetch = globalThis.fetch; + requests = []; + traceStatus = 200; + traceResponses = 0; + regionGate = undefined; + dashboardGate = undefined; + iterators = []; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + requests.push(request); + const path = new URL(request.url).pathname; + let body: unknown; + let status = 200; + if (path === `/api/0/organizations/${ORG}/`) { + await regionGate?.promise; + body = { id: "1", slug: ORG, links: { regionUrl: HOST } }; + } else if (path === `/api/0/organizations/${ORG}/dashboards/1/`) { + await dashboardGate?.promise; + body = { id: "1", title: "Synthetic dashboard", widgets: [] }; + } else if (path === `/api/0/organizations/${ORG}/trace-logs/`) { + traceResponses += 1; + status = traceStatus; + body = + status === 200 + ? { + data: [ + { + ...TRACE_LOG, + id: `log-${traceResponses}`, + timestamp_precise: + (TRACE_LOG.timestamp_precise ?? 0) + + traceResponses * 1_000_000, + }, + ], + } + : { detail: "Invalid token" }; + } else if (path === `/api/0${ENDPOINT}`) { + body = { + owner: + request.headers.get("Authorization") === `Bearer ${SECOND_TOKEN}` + ? "second" + : "first", + }; + } else { + status = 404; + body = { detail: "Unexpected request" }; + } + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + }); + }); + + afterEach(async () => { + regionGate?.resolve(); + dashboardGate?.resolve(); + for (const iterator of iterators) { + await iterator.return?.(); + } + globalThis.fetch = originalFetch; + }); + + /** Create a client without accessing the developer's project or credentials. */ + function client(token: string, options?: SentryOptions) { + return createSentrySDK({ + cwd: getConfigDir(), + url: HOST, + token, + ...options, + }); + } + + /** Open the public typed or argument-based follow entry point. */ + function stream( + sdk: ReturnType, + entryPoint: "typed" | "run" = "typed", + ): AsyncIterable { + const result = + entryPoint === "typed" + ? sdk.log.list({ follow: "1" }, `${ORG}/${TRACE_ID}`) + : sdk.run("log", "list", `${ORG}/${TRACE_ID}`, "--follow", "1"); + const iterable = result as AsyncIterable; + iterators.push(iterable[Symbol.asyncIterator]()); + return iterable; + } + + /** Exercise a new client immediately after streaming terminates. */ + function secondClientRequest() { + return client(SECOND_TOKEN).api({ endpoint: ENDPOINT }); + } + + test.each(["typed", "run"] as const)( + "%s consumer break finishes cleanup before the next call", + async (entryPoint) => { + const envBefore = { ...process.env }; + let received = false; + for await (const item of stream(client(FIRST_TOKEN), entryPoint)) { + expect(item).toMatchObject({ data: [{ id: "log-1" }] }); + received = true; + break; + } + expect(received).toBe(true); + expect(process.env).toEqual(envBefore); + await expect(secondClientRequest()).resolves.toMatchObject({ + body: { owner: "second" }, + }); + }, + ); + + test("AbortSignal completion leaves the next invocation usable", async () => { + const controller = new AbortController(); + const iterator = stream(client(FIRST_TOKEN, { signal: controller.signal }))[ + Symbol.asyncIterator + ](); + expect((await iterator.next()).done).toBe(false); + + controller.abort(); + await expect(iterator.next()).resolves.toMatchObject({ done: true }); + await expect(secondClientRequest()).resolves.toMatchObject({ + body: { owner: "second" }, + }); + }); + + test("stream authentication errors finish cleanup before rejection", async () => { + traceStatus = 401; + const iterator = stream(client(FIRST_TOKEN))[Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toBeInstanceOf(SentryError); + expect(traceResponses).toBe(1); + await expect(secondClientRequest()).resolves.toMatchObject({ + body: { owner: "second" }, + }); + }); + + test("immediate iterator return stops before the producer starts", async () => { + const iterator = stream(client(FIRST_TOKEN))[Symbol.asyncIterator](); + await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); + expect(requests).toHaveLength(0); + await expect(secondClientRequest()).resolves.toMatchObject({ + body: { owner: "second" }, + }); + }); + + test("return during region lookup waits for stream cleanup", async () => { + regionGate = deferred(); + const iterator = stream(client(FIRST_TOKEN))[Symbol.asyncIterator](); + await vi.waitFor(() => { + expect(requests).toHaveLength(1); + }); + const returned = iterator.return?.(); + regionGate.resolve(); + await expect(returned).resolves.toMatchObject({ done: true }); + await expect(secondClientRequest()).resolves.toMatchObject({ + body: { owner: "second" }, + }); + }); + + test("dashboard abort during initial fetch stops before refresh starts", async () => { + dashboardGate = deferred(); + const controller = new AbortController(); + const result = client(FIRST_TOKEN, { + signal: controller.signal, + }).dashboard.view({ refresh: "10" }, `${ORG}/`, "1"); + const iterator = (result as AsyncIterable)[Symbol.asyncIterator](); + iterators.push(iterator); + await vi.waitFor( + () => { + expect(requests.at(-1)?.url).toContain("/dashboards/1/"); + }, + { timeout: 5000 }, + ); + + controller.abort(); + dashboardGate.resolve(); + await expect(iterator.next()).resolves.toMatchObject({ done: true }); + await expect(secondClientRequest()).resolves.toMatchObject({ + body: { owner: "second" }, + }); + }); + + test("overlapping calls fail without altering the active stream", async () => { + const iterator = stream(client(FIRST_TOKEN))[Symbol.asyncIterator](); + expect((await iterator.next()).value).toMatchObject({ + data: [{ id: "log-1" }], + }); + const second = client(SECOND_TOKEN); + await expect(second.api({ endpoint: ENDPOINT })).rejects.toThrow( + "Concurrent SDK calls are not supported", + ); + const overlapping = stream(second)[Symbol.asyncIterator](); + await expect(overlapping.next()).rejects.toThrow( + "Concurrent SDK calls are not supported", + ); + + expect((await iterator.next()).value).toMatchObject({ id: "log-2" }); + expect( + requests.map((request) => request.headers.get("Authorization")), + ).toEqual(requests.map(() => `Bearer ${FIRST_TOKEN}`)); + await iterator.return?.(); + await expect(secondClientRequest()).resolves.toMatchObject({ + body: { owner: "second" }, + }); + }); +}); diff --git a/packages/cli/test/lib/security/sntrys-claim-mismatch.test.ts b/packages/cli/test/lib/security/sntrys-claim-mismatch.test.ts index 50d76f63c..1d5bffa83 100644 --- a/packages/cli/test/lib/security/sntrys-claim-mismatch.test.ts +++ b/packages/cli/test/lib/security/sntrys-claim-mismatch.test.ts @@ -31,18 +31,14 @@ describe("CVE defense-in-depth: sntrys_ claim vs request mismatch", () => { beforeEach(async () => { await resetHostScopingState(); - const { - resetAuthTokenCache, - resetAuthRowCache, - resetIdentityFingerprintCache, - } = await import("../../../src/lib/db/auth.js"); + const { resetAuthTokenCache, resetIdentityFingerprintCache } = + await import("../../../src/lib/db/auth.js"); // Clear the GET response cache between tests so a cached 200 from // a prior test's identical URL doesn't short-circuit the fetch // wrapper (which would leave `fetchCalls` empty). const { clearResponseCache } = await import("../../../src/lib/response-cache.js"); resetAuthTokenCache(); - resetAuthRowCache(); resetIdentityFingerprintCache(); await clearResponseCache(); diff --git a/packages/cli/test/lib/sentry-client-refresh-cache.focused.test.ts b/packages/cli/test/lib/sentry-client-refresh-cache.focused.test.ts index e2e4fec43..a82e984a0 100644 --- a/packages/cli/test/lib/sentry-client-refresh-cache.focused.test.ts +++ b/packages/cli/test/lib/sentry-client-refresh-cache.focused.test.ts @@ -73,12 +73,10 @@ test("401 refresh caches GET under the bearer actually sent on retry", async () expect(await response.json()).toEqual({ id: "refresh-cache-focused" }); expect(requests).toEqual(["Bearer old-access", "Bearer new-access"]); await vi.waitFor(async () => { - const cached = await getCachedResponse( - "GET", - url, - { authorization: "Bearer new-access" }, - getIdentityFingerprint(), - ); + const cached = await getCachedResponse("GET", url, { + headers: { authorization: "Bearer new-access" }, + identity: getIdentityFingerprint(), + }); expect(cached).toBeDefined(); }); }); diff --git a/packages/cli/test/lib/sentry-client.auth.test.ts b/packages/cli/test/lib/sentry-client.auth.test.ts index 10f248283..cc36776e7 100644 --- a/packages/cli/test/lib/sentry-client.auth.test.ts +++ b/packages/cli/test/lib/sentry-client.auth.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { shouldAutoAuth } from "../../src/lib/auto-auth.js"; -import { getAuthConfig, setAuthToken } from "../../src/lib/db/auth.js"; +import { + getAuthConfig, + getIdentityFingerprint, + setAuthToken, +} from "../../src/lib/db/auth.js"; import { getDatabase } from "../../src/lib/db/index.js"; import { setEnv } from "../../src/lib/env.js"; import { @@ -212,7 +216,10 @@ describe("authenticated fetch bearer validation", () => { await storeCachedResponse( "GET", RESOURCE_URL, - { authorization: "Bearer synthetic-token" }, + { + identity: getIdentityFingerprint(), + headers: { authorization: "Bearer synthetic-token" }, + }, Response.json( { source: "cache" }, { @@ -248,7 +255,10 @@ describe("authenticated fetch bearer validation", () => { await expect .poll(async () => { const cached = await getCachedResponse("GET", RESOURCE_URL, { - authorization: "Bearer synthetic-token", + identity: getIdentityFingerprint(), + headers: { + authorization: "Bearer synthetic-token", + }, }); return cached?.json(); }) diff --git a/packages/cli/test/lib/sentry-client.identity.test.ts b/packages/cli/test/lib/sentry-client.identity.test.ts new file mode 100644 index 000000000..b4ac596df --- /dev/null +++ b/packages/cli/test/lib/sentry-client.identity.test.ts @@ -0,0 +1,262 @@ +/** Authenticated transport cache ownership across changes to stored credentials. */ + +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + getAuthToken, + getIdentityFingerprint, + setAuthToken, +} from "../../src/lib/db/auth.js"; +import { withEnv } from "../../src/lib/env.js"; +import { getSdkConfig } from "../../src/lib/sentry-client.js"; +import { mockFetch, useEnvSandbox, useTestConfigDir } from "../helpers.js"; + +const HOST = "https://synthetic.example.invalid"; +const ENDPOINT = `${HOST}/api/0/organizations/synthetic-org/projects/`; +const TOKENS = { A: "synthetic-token-a", B: "synthetic-token-b" }; +type Owner = keyof typeof TOKENS; + +function deferred() { + let resolve = () => { + // Assigned by the promise executor before this helper returns. + }; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("authenticated response cache identity", () => { + const getConfigDir = useTestConfigDir("sentry-client-identity-"); + useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_NO_CACHE", + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_CUSTOM_HEADERS", + ]); + + let originalFetch: typeof globalThis.fetch; + let requests: Array; + let pendingResponse: ReturnType | undefined; + let requestStarted: ReturnType | undefined; + + beforeEach(() => { + originalFetch = globalThis.fetch; + requests = []; + pendingResponse = undefined; + requestStarted = undefined; + process.env.SENTRY_HOST = HOST; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + if (request.url !== ENDPOINT) { + throw new Error("Unexpected test request"); + } + const authorization = request.headers.get("Authorization"); + const owner = authorization === `Bearer ${TOKENS.A}` ? "A" : "B"; + requests.push(authorization); + requestStarted?.resolve(); + await pendingResponse?.promise; + // No Vary header: credential isolation must hold independently of it. + return Response.json( + { owner }, + { headers: { "Cache-Control": "private, max-age=300" } }, + ); + }); + login("A"); + }); + + afterEach(() => { + pendingResponse?.resolve(); + globalThis.fetch = originalFetch; + }); + + function invocation(run: () => T): T { + return withEnv({ ...process.env }, run); + } + + function login(owner: Owner): void { + setAuthToken(TOKENS[owner], 3600, `synthetic-refresh-${owner}`); + } + + async function requestBody() { + const response = await getSdkConfig(HOST).fetch(ENDPOINT); + return response.json(); + } + + async function waitForCacheEntries(count: number): Promise { + await vi.waitUntil(async () => { + const files = await readdir( + join(getConfigDir(), "cache", "responses"), + ).catch(() => []); + return files.filter((file) => file.endsWith(".json")).length === count; + }); + } + + test("a session change before sending cannot cache B's response under A", async () => { + await invocation(async () => { + expect(getAuthToken()).toBe(TOKENS.A); + getIdentityFingerprint(); + invocation(() => login("B")); + + expect(await requestBody()).toEqual({ owner: "B" }); + await waitForCacheEntries(1); + }); + + await invocation(async () => { + login("A"); + expect(await requestBody()).toEqual({ owner: "A" }); + await waitForCacheEntries(2); + }); + expect(requests).toEqual([`Bearer ${TOKENS.B}`, `Bearer ${TOKENS.A}`]); + }); + + test("an existing invocation cannot reuse A's cache after switching to B", async () => { + await invocation(async () => { + expect(await requestBody()).toEqual({ owner: "A" }); + await waitForCacheEntries(1); + invocation(() => login("B")); + + expect(await requestBody()).toEqual({ owner: "B" }); + await waitForCacheEntries(2); + }); + expect(requests).toEqual([`Bearer ${TOKENS.A}`, `Bearer ${TOKENS.B}`]); + }); + + test("access tokens with an empty refresh token keep separate cache identities", async () => { + await invocation(async () => { + setAuthToken(TOKENS.A, 3600, ""); + expect(await requestBody()).toEqual({ owner: "A" }); + await waitForCacheEntries(1); + setAuthToken(TOKENS.B, 3600, ""); + + expect(await requestBody()).toEqual({ owner: "B" }); + await waitForCacheEntries(2); + }); + expect(requests).toEqual([`Bearer ${TOKENS.A}`, `Bearer ${TOKENS.B}`]); + }); + + test("an in-flight A response keeps A's cache identity after the session changes", async () => { + pendingResponse = deferred(); + requestStarted = deferred(); + await invocation(async () => { + const response = requestBody(); + await requestStarted?.promise; + invocation(() => login("B")); + pendingResponse?.resolve(); + + expect(await response).toEqual({ owner: "A" }); + await waitForCacheEntries(1); + await invocation(async () => { + expect(await requestBody()).toEqual({ owner: "B" }); + await waitForCacheEntries(2); + }); + }); + + await invocation(async () => { + login("A"); + expect(await requestBody()).toEqual({ owner: "A" }); + }); + expect(requests).toEqual([`Bearer ${TOKENS.A}`, `Bearer ${TOKENS.B}`]); + }); + + test("a 401 retry preserves the pinned identity while the rotated session warms its own cache", async () => { + const respond = globalThis.fetch; + const calls: string[] = []; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + if (request.url === `${HOST}/oauth/token/`) { + calls.push("refresh"); + expect( + new URLSearchParams(await request.text()).get("refresh_token"), + ).toBe("synthetic-refresh-A"); + return Response.json({ + access_token: TOKENS.B, + refresh_token: "synthetic-refresh-B", + expires_in: 3600, + token_type: "bearer", + }); + } + const authorization = request.headers.get("Authorization"); + calls.push(authorization ?? "missing authorization"); + if (authorization === `Bearer ${TOKENS.A}`) { + return Response.json({ detail: "Expired token" }, { status: 401 }); + } + return respond(input, init); + }); + + await invocation(async () => { + expect(await requestBody()).toEqual({ owner: "B" }); + await waitForCacheEntries(1); + }); + await invocation(async () => { + expect(await requestBody()).toEqual({ owner: "B" }); + await waitForCacheEntries(2); + }); + await invocation(async () => { + expect(await requestBody()).toEqual({ owner: "B" }); + }); + expect(calls).toEqual([ + `Bearer ${TOKENS.A}`, + "refresh", + `Bearer ${TOKENS.B}`, + `Bearer ${TOKENS.B}`, + ]); + }); + + test("a delayed mutation invalidates only the identity that sent it", async () => { + await invocation(async () => { + await requestBody(); + await waitForCacheEntries(1); + }); + await invocation(async () => { + login("B"); + await requestBody(); + await waitForCacheEntries(2); + }); + + const respond = globalThis.fetch; + pendingResponse = deferred(); + requestStarted = deferred(); + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + if (request.method !== "PUT") { + return respond(input, init); + } + requests.push(request.headers.get("Authorization")); + requestStarted?.resolve(); + await pendingResponse?.promise; + return new Response(null, { status: 204 }); + }); + + await invocation(async () => { + login("A"); + const mutation = getSdkConfig(HOST).fetch( + `${HOST}/api/0/projects/synthetic-org/synthetic-project/`, + { method: "PUT" }, + ); + await requestStarted?.promise; + invocation(() => login("B")); + pendingResponse?.resolve(); + expect((await mutation).status).toBe(204); + await waitForCacheEntries(1); + }); + await invocation(async () => { + expect(await requestBody()).toEqual({ owner: "B" }); + }); + expect(requests).toEqual([ + `Bearer ${TOKENS.A}`, + `Bearer ${TOKENS.B}`, + `Bearer ${TOKENS.A}`, + ]); + await invocation(async () => { + login("A"); + expect(await requestBody()).toEqual({ owner: "A" }); + await waitForCacheEntries(2); + }); + expect(requests).toHaveLength(4); + }); +}); diff --git a/packages/cli/test/lib/sentry-client.invalidation.test.ts b/packages/cli/test/lib/sentry-client.invalidation.test.ts index 80da1f885..a0a36fa5d 100644 --- a/packages/cli/test/lib/sentry-client.invalidation.test.ts +++ b/packages/cli/test/lib/sentry-client.invalidation.test.ts @@ -9,7 +9,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { setAuthToken } from "../../src/lib/db/auth.js"; +import { getIdentityFingerprint, setAuthToken } from "../../src/lib/db/auth.js"; import { getCachedResponse, storeCachedResponse, @@ -70,13 +70,13 @@ describe("HTTP-layer auto-invalidation", () => { await storeCachedResponse( "GET", DETAIL_URL, - {}, + { identity: getIdentityFingerprint(), headers: {} }, makeResponse({ id: "12345" }), ); await storeCachedResponse( "GET", `${LIST_URL}?cursor=abc`, - {}, + { identity: getIdentityFingerprint(), headers: {} }, makeResponse({ data: [] }), ); @@ -90,9 +90,17 @@ describe("HTTP-layer auto-invalidation", () => { // Invalidation is awaited inside the hook, so the cache is // already cleared when the caller sees the response. - expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeUndefined(); expect( - await getCachedResponse("GET", `${LIST_URL}?cursor=abc`, {}), + await getCachedResponse("GET", DETAIL_URL, { + identity: getIdentityFingerprint(), + headers: {}, + }), + ).toBeUndefined(); + expect( + await getCachedResponse("GET", `${LIST_URL}?cursor=abc`, { + identity: getIdentityFingerprint(), + headers: {}, + }), ).toBeUndefined(); }); @@ -100,21 +108,26 @@ describe("HTTP-layer auto-invalidation", () => { await storeCachedResponse( "GET", DETAIL_URL, - {}, + { identity: getIdentityFingerprint(), headers: {} }, makeResponse({ id: "12345" }), ); installMockFetch(async () => makeResponse({ error: "denied" }, 403)); const response = await runAuthenticatedFetch(DETAIL_URL, "PUT"); expect(response.status).toBe(403); - expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + expect( + await getCachedResponse("GET", DETAIL_URL, { + identity: getIdentityFingerprint(), + headers: {}, + }), + ).toBeDefined(); }); test("GET does NOT invalidate the cache", async () => { await storeCachedResponse( "GET", DETAIL_URL, - {}, + { identity: getIdentityFingerprint(), headers: {} }, makeResponse({ id: "12345" }), ); @@ -123,7 +136,12 @@ describe("HTTP-layer auto-invalidation", () => { `${BASE}organizations/acme/issues/99999/`, "GET", ); - expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + expect( + await getCachedResponse("GET", DETAIL_URL, { + identity: getIdentityFingerprint(), + headers: {}, + }), + ).toBeDefined(); }); test("cross-endpoint rule fires for project delete", async () => { @@ -131,7 +149,7 @@ describe("HTTP-layer auto-invalidation", () => { await storeCachedResponse( "GET", `${orgListUrl}?cursor=xyz`, - {}, + { identity: getIdentityFingerprint(), headers: {} }, makeResponse({ data: [] }), ); @@ -139,7 +157,10 @@ describe("HTTP-layer auto-invalidation", () => { await runAuthenticatedFetch(`${BASE}projects/acme/frontend/`, "DELETE"); expect( - await getCachedResponse("GET", `${orgListUrl}?cursor=xyz`, {}), + await getCachedResponse("GET", `${orgListUrl}?cursor=xyz`, { + identity: getIdentityFingerprint(), + headers: {}, + }), ).toBeUndefined(); }); @@ -148,7 +169,7 @@ describe("HTTP-layer auto-invalidation", () => { await storeCachedResponse( "GET", DETAIL_URL, - {}, + { identity: getIdentityFingerprint(), headers: {} }, makeResponse({ owner: "a" }), ); @@ -157,6 +178,11 @@ describe("HTTP-layer auto-invalidation", () => { await runAuthenticatedFetch(DETAIL_URL, "PUT"); setAuthToken("identity-a", 3600, "refresh-a"); - expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + expect( + await getCachedResponse("GET", DETAIL_URL, { + identity: getIdentityFingerprint(), + headers: {}, + }), + ).toBeDefined(); }); });