Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 55 additions & 50 deletions AGENTS.md

Large diffs are not rendered by default.

50 changes: 44 additions & 6 deletions src/lib/db/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,25 @@ export function getAuthConfig(): AuthConfig | undefined {
return;
}

/** Memoized token. Wrapper distinguishes "not cached" from "cached as undefined". */
let cachedAuthToken: { value: string | undefined } | undefined;

/**
* Get the active auth token.
*
* Default: checks the DB first (stored OAuth wins), then falls back to env vars.
* With `SENTRY_FORCE_ENV_TOKEN=1`: checks env vars first (old behavior).
*/
export function getAuthToken(): string | undefined {
if (cachedAuthToken !== undefined) {
return cachedAuthToken.value;
}
const value = computeAuthToken();
cachedAuthToken = { value };
return value;
}

function computeAuthToken(): string | undefined {
const forceEnv = getEnv().SENTRY_FORCE_ENV_TOKEN?.trim();
if (forceEnv) {
const envToken = getEnvToken();
Expand Down Expand Up @@ -196,6 +208,31 @@ export function getAuthToken(): string | undefined {
return;
}

/** Reset the memoized auth token. Tests only — call between auth-state mutations. */
export function resetAuthTokenCache(): void {
cachedAuthToken = undefined;
}

/** Memoized full auth row for {@link refreshToken}. Same wrapper contract as {@link cachedAuthToken}. */
let cachedAuthRow: { value: AuthRow | undefined } | undefined;

function getCachedAuthRow(): AuthRow | undefined {
if (cachedAuthRow !== undefined) {
return cachedAuthRow.value;
}
const db = getDatabase();
const row = db.query("SELECT * FROM auth WHERE id = 1").get() as
| AuthRow
| undefined;
cachedAuthRow = { value: row };
return row;
}

/** Reset the memoized auth row. Tests only — call between auth-state mutations. */
export function resetAuthRowCache(): void {
cachedAuthRow = undefined;
}

export function setAuthToken(
token: string,
expiresIn?: number,
Expand All @@ -221,9 +258,11 @@ export function setAuthToken(
["id"]
);
});
// Auth row changed — drop the memoized fingerprint so the next
// `getIdentityFingerprint()` call reflects the new row.
// Auth row changed — drop memoized fingerprint, token, and row so the next
// read reflects the new row.
resetIdentityFingerprintCache();
resetAuthTokenCache();
resetAuthRowCache();
}

export async function clearAuth(): Promise<void> {
Expand All @@ -239,6 +278,8 @@ export async function clearAuth(): Promise<void> {
clearAllIssueOrgCache();
});
resetIdentityFingerprintCache();
resetAuthTokenCache();
resetAuthRowCache();

// Dynamic import avoids the auth→response-cache→auth cycle.
try {
Expand Down Expand Up @@ -424,10 +465,7 @@ export async function refreshToken(
const { force = false } = options;
const { AuthError } = await import("../errors.js");

const db = getDatabase();
const row = db.query("SELECT * FROM auth WHERE id = 1").get() as
| AuthRow
| undefined;
const row = getCachedAuthRow();

if (!row?.token) {
// No stored token — try env token as fallback
Expand Down
12 changes: 12 additions & 0 deletions test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { afterEach, beforeEach } from "bun:test";
import { mkdirSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import {
resetAuthRowCache,
resetAuthTokenCache,
resetIdentityFingerprintCache,
} from "../src/lib/db/auth.js";
import { CONFIG_DIR_ENV_VAR, closeDatabase } from "../src/lib/db/index.js";

// biome-ignore lint/performance/noBarrelFile: re-exporting a single constant, not a barrel
Expand Down Expand Up @@ -105,12 +110,19 @@ export function useTestConfigDir(
beforeEach(async () => {
savedConfigDir = process.env[CONFIG_DIR_ENV_VAR];
closeDatabase();
// Fresh DB — drop module-scoped auth caches from the previous test.
resetAuthTokenCache();
resetAuthRowCache();
resetIdentityFingerprintCache();
dir = await createTestConfigDir(prefix, options);
process.env[CONFIG_DIR_ENV_VAR] = dir;
});

afterEach(async () => {
closeDatabase();
resetAuthTokenCache();
resetAuthRowCache();
resetIdentityFingerprintCache();
// Always restore the previous value — never delete.
// Deleting process.env.SENTRY_CONFIG_DIR causes failures in test files
// that load after this afterEach runs, because their module-level code
Expand Down
22 changes: 22 additions & 0 deletions test/lib/db/auth.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
getAuthToken,
isEnvTokenActive,
refreshToken,
resetAuthRowCache,
resetAuthTokenCache,
setAuthToken,
} from "../../../src/lib/db/auth.js";
import { useTestConfigDir } from "../../helpers.js";
Expand All @@ -43,6 +45,8 @@ beforeEach(() => {
savedSentryToken = process.env.SENTRY_TOKEN;
delete process.env.SENTRY_AUTH_TOKEN;
delete process.env.SENTRY_TOKEN;
resetAuthTokenCache();
resetAuthRowCache();
});

afterEach(() => {
Expand All @@ -58,10 +62,17 @@ afterEach(() => {
}
});

/** Invalidate between property iterations — env-var mutations bypass setAuthToken. */
function resetAuthCaches() {
resetAuthTokenCache();
resetAuthRowCache();
}

describe("property: env var priority", () => {
test("SENTRY_AUTH_TOKEN always wins over SENTRY_TOKEN", () => {
fcAssert(
property(tokenArb, tokenArb, (authToken, sentryToken) => {
resetAuthCaches();
process.env.SENTRY_AUTH_TOKEN = authToken;
process.env.SENTRY_TOKEN = sentryToken;

Expand All @@ -77,6 +88,7 @@ describe("property: env var priority", () => {
test("stored OAuth wins over env var (default behavior)", () => {
fcAssert(
property(tokenArb, tokenArb, (envToken, storedToken) => {
resetAuthCaches();
setAuthToken(storedToken);
process.env.SENTRY_AUTH_TOKEN = envToken;

Expand All @@ -91,16 +103,19 @@ describe("property: env var priority", () => {
test("SENTRY_FORCE_ENV_TOKEN overrides stored OAuth", () => {
fcAssert(
property(tokenArb, tokenArb, (envToken, storedToken) => {
resetAuthCaches();
setAuthToken(storedToken);
process.env.SENTRY_AUTH_TOKEN = envToken;
try {
process.env.SENTRY_FORCE_ENV_TOKEN = "1";
resetAuthCaches();
expect(getAuthToken()).toBe(envToken.trim());
expect(getAuthConfig()?.source).toBe(
"env:SENTRY_AUTH_TOKEN" satisfies AuthSource
);
} finally {
delete process.env.SENTRY_FORCE_ENV_TOKEN;
resetAuthCaches();
}
}),
{ numRuns: DEFAULT_NUM_RUNS }
Expand All @@ -110,6 +125,7 @@ describe("property: env var priority", () => {
test("stored token used when no env vars set", () => {
fcAssert(
property(tokenArb, (storedToken) => {
resetAuthCaches();
setAuthToken(storedToken);

expect(getAuthToken()).toBe(storedToken);
Expand All @@ -124,6 +140,7 @@ describe("property: env tokens never trigger refresh", () => {
test("refreshToken returns env token without refreshing", async () => {
await fcAssert(
asyncProperty(tokenArb, async (envToken) => {
resetAuthCaches();
process.env.SENTRY_AUTH_TOKEN = envToken;

const result = await refreshToken();
Expand All @@ -139,6 +156,7 @@ describe("property: env tokens never trigger refresh", () => {
test("refreshToken with force=true still returns env token without refreshing", async () => {
await fcAssert(
asyncProperty(tokenArb, async (envToken) => {
resetAuthCaches();
process.env.SENTRY_AUTH_TOKEN = envToken;

const result = await refreshToken({ force: true });
Expand All @@ -154,6 +172,7 @@ describe("property: isEnvTokenActive consistency", () => {
test("when no env token, getAuthConfig never returns env source", () => {
fcAssert(
property(option(tokenArb), (storedTokenOpt) => {
resetAuthCaches();
// Clean slate — no env tokens
delete process.env.SENTRY_AUTH_TOKEN;
delete process.env.SENTRY_TOKEN;
Expand All @@ -177,6 +196,7 @@ describe("property: isEnvTokenActive consistency", () => {
test("stored OAuth takes priority: getAuthConfig returns oauth even when env token is set", () => {
fcAssert(
property(tokenArb, tokenArb, (envToken, storedToken) => {
resetAuthCaches();
process.env.SENTRY_AUTH_TOKEN = envToken;
setAuthToken(storedToken);

Expand All @@ -194,6 +214,7 @@ describe("property: source round-trip", () => {
test("source correctly identifies SENTRY_AUTH_TOKEN", () => {
fcAssert(
property(tokenArb, (token) => {
resetAuthCaches();
process.env.SENTRY_AUTH_TOKEN = token;
const config = getAuthConfig();
expect(config?.source).toBe("env:SENTRY_AUTH_TOKEN");
Expand All @@ -207,6 +228,7 @@ describe("property: source round-trip", () => {
test("source correctly identifies SENTRY_TOKEN", () => {
fcAssert(
property(tokenArb, (token) => {
resetAuthCaches();
process.env.SENTRY_TOKEN = token;
const config = getAuthConfig();
expect(config?.source).toBe("env:SENTRY_TOKEN");
Expand Down
93 changes: 92 additions & 1 deletion test/lib/db/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
ANON_IDENTITY,
clearAuth,
getActiveEnvVarName,
getAuthConfig,
getAuthToken,
Expand All @@ -18,9 +19,12 @@ import {
isAuthenticated,
isEnvTokenActive,
refreshToken,
resetAuthRowCache,
resetAuthTokenCache,
resetIdentityFingerprintCache,
setAuthToken,
} from "../../../src/lib/db/auth.js";
import { getDatabase } from "../../../src/lib/db/index.js";
import { useTestConfigDir } from "../../helpers.js";

useTestConfigDir("auth-env-");
Expand All @@ -34,6 +38,8 @@ beforeEach(() => {
delete process.env.SENTRY_AUTH_TOKEN;
delete process.env.SENTRY_TOKEN;
resetIdentityFingerprintCache();
resetAuthTokenCache();
resetAuthRowCache();
});

afterEach(() => {
Expand Down Expand Up @@ -204,7 +210,6 @@ describe("OAuth-preferred auth (#646)", () => {

describe("clearAuth: integration with per-account caches", () => {
test("clearAuth drops issue_org_cache entries (prevents cross-account leakage)", async () => {
const { clearAuth } = await import("../../../src/lib/db/auth.js");
const { setCachedIssueOrg, getCachedIssueOrg } = await import(
"../../../src/lib/db/issue-org-cache.js"
);
Expand Down Expand Up @@ -323,3 +328,89 @@ describe("getIdentityFingerprint", () => {
expect(getIdentityFingerprint()).toBe(fp);
});
});

describe("getAuthToken memoization", () => {
test("returns cached value on repeated calls without re-reading the DB", () => {
setAuthToken("stored_token");
const first = getAuthToken();
expect(first).toBe("stored_token");

// Mutate the DB row directly behind getAuthToken's back — a cached
// read must not reflect this change until the cache is invalidated.
getDatabase().query("UPDATE auth SET token = 'mutated' WHERE id = 1").run();

// Still returns the cached value
expect(getAuthToken()).toBe("stored_token");

// After reset, the new value is read
resetAuthTokenCache();
expect(getAuthToken()).toBe("mutated");
});

test("caches the logged-out state (undefined) without re-reading", () => {
expect(getAuthToken()).toBeUndefined();

// Write a token directly to DB, bypassing setAuthToken. The cached
// undefined must persist until invalidated.
getDatabase()
.query("INSERT INTO auth (id, token, updated_at) VALUES (1, 'sneaky', ?)")
.run(Date.now());

expect(getAuthToken()).toBeUndefined();

resetAuthTokenCache();
expect(getAuthToken()).toBe("sneaky");
});

test("setAuthToken invalidates the cache", () => {
setAuthToken("token_a");
expect(getAuthToken()).toBe("token_a");

setAuthToken("token_b");
// No manual reset — setAuthToken must have invalidated the cache
expect(getAuthToken()).toBe("token_b");
});

test("clearAuth invalidates the cache", async () => {
setAuthToken("token_to_clear");
expect(getAuthToken()).toBe("token_to_clear");

await clearAuth();
expect(getAuthToken()).toBeUndefined();
});

test("env-var change requires manual cache reset (documented contract)", () => {
expect(getAuthToken()).toBeUndefined();

// Env mutation without reset: cache stays stale (by design).
process.env.SENTRY_AUTH_TOKEN = "env_token";
expect(getAuthToken()).toBeUndefined();

resetAuthTokenCache();
expect(getAuthToken()).toBe("env_token");
});
});

describe("refreshToken row-read memoization", () => {
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.
setAuthToken("first_token", 3600, "refresh_1");
const r1 = await refreshToken();
expect(r1.token).toBe("first_token");

setAuthToken("second_token", 3600, "refresh_2");
const r2 = await refreshToken();
expect(r2.token).toBe("second_token");
});

test("clearAuth invalidates the row cache", async () => {
setAuthToken("will_be_cleared", 3600, "refresh_x");
const r1 = await refreshToken();
expect(r1.token).toBe("will_be_cleared");

await clearAuth();
// With nothing stored and no env var, refreshToken throws not_authenticated
await expect(refreshToken()).rejects.toThrow();
});
});
Loading
Loading