diff --git a/src/lib/api/dashboards.ts b/src/lib/api/dashboards.ts index 4302d97a5..9bc8b5ccd 100644 --- a/src/lib/api/dashboards.ts +++ b/src/lib/api/dashboards.ts @@ -27,7 +27,6 @@ import { } from "../../types/dashboard.js"; import { stringifyUnknown } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; -import { invalidateCachedResponse } from "../response-cache.js"; import { apiRequestToRegion, @@ -125,14 +124,6 @@ export async function updateDashboard( method: "PUT", body, }); - - // Invalidate cached GET for this dashboard so subsequent view commands - // return fresh data instead of the pre-mutation cached response. - const normalizedBase = regionUrl.endsWith("/") - ? regionUrl.slice(0, -1) - : regionUrl; - await invalidateCachedResponse(`${normalizedBase}/api/0${path}`); - return data; } diff --git a/src/lib/api/issues.ts b/src/lib/api/issues.ts index 43ac6736d..d2ec5d347 100644 --- a/src/lib/api/issues.ts +++ b/src/lib/api/issues.ts @@ -12,14 +12,11 @@ import type { SentryIssue } from "../../types/index.js"; import { applyCustomHeaders } from "../custom-headers.js"; import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; -import { invalidateCachedResponsesMatching } from "../response-cache.js"; -import { getApiBaseUrl } from "../sentry-client.js"; import { API_MAX_PER_PAGE, apiRequest, apiRequestToRegion, - buildApiUrl, getOrgSdkConfig, MAX_PAGINATION_PAGES, type PaginatedResponse, @@ -566,22 +563,14 @@ export async function updateIssueStatus( `/organizations/${encodeURIComponent(options.orgSlug)}/issues/${encodeURIComponent(issueId)}/`, { method: "PUT", body } ); - await invalidateIssueCaches(regionUrl, options.orgSlug, issueId); return data; } - // Legacy global endpoint — works without org but not region-aware, - // so we can only flush the legacy issue-detail URL. Region-scoped - // lists age out via their TTL. Prefix-sweep (not exact-match) - // because `getIssue` caches under `.../issues/{id}/?collapse=...`. - const legacyData = await apiRequest( - `/issues/${encodeURIComponent(issueId)}/`, - { method: "PUT", body } - ); - await invalidateCachedResponsesMatching( - buildApiUrl(getApiBaseUrl(), "issues", issueId) - ); - return legacyData; + // Legacy global endpoint — works without org but not region-aware. + return apiRequest(`/issues/${encodeURIComponent(issueId)}/`, { + method: "PUT", + body, + }); } /** Result of a successful issue-merge operation. */ @@ -625,15 +614,6 @@ export async function mergeIssues( method: "PUT", body: { merge: 1 }, }); - // Flush detail caches for every affected ID (detail-only avoids - // N+1 list scans) then sweep the org-wide list once. - const affectedIds = data.merge.children.toSpliced(0, 0, data.merge.parent); - await Promise.all( - affectedIds.map((id) => - invalidateIssueDetailCaches(regionUrl, orgSlug, id) - ) - ); - await invalidateOrgIssueList(regionUrl, orgSlug); return data.merge; } catch (error) { // The bulk-mutate endpoint returns 204 when no matching issues are @@ -693,77 +673,3 @@ export async function getSharedIssue( return (await response.json()) as { groupID: string }; } - -/** - * Flush both the org-scoped and legacy detail endpoints for one issue, - * including all `collapse` query-param variants (`getIssueInOrg` caches - * responses under URLs like `.../issues/{id}/?collapse=stats&...` so - * exact-match invalidation would miss them). Does NOT sweep the - * org-wide list — callers must call {@link invalidateOrgIssueList} - * once per operation. Never throws. - */ -async function invalidateIssueDetailCaches( - regionUrl: string, - orgSlug: string, - issueId: string -): Promise { - try { - await Promise.all([ - invalidateCachedResponsesMatching( - buildApiUrl(regionUrl, "organizations", orgSlug, "issues", issueId) - ), - // Legacy `/api/0/issues/{id}/` is stored under the non-region base - // (see `apiRequest` → `getApiBaseUrl`), NOT the org's region URL. - invalidateCachedResponsesMatching( - buildApiUrl(getApiBaseUrl(), "issues", issueId) - ), - ]); - } catch { - /* best-effort: mutation already succeeded upstream */ - } -} - -/** - * Flush detail + list caches for one issue. Use for single-issue - * mutations (resolve, unresolve); batch mutations should use the - * detail-only helper plus one final {@link invalidateOrgIssueList}. - * - * Minor redundancy: the org-scoped half of - * {@link invalidateIssueDetailCaches} is already a prefix of the - * {@link invalidateOrgIssueList} sweep. Accepted because the helpers - * are each also used solo elsewhere, and the extra directory walk is - * negligible. - * - * Never throws. - */ -async function invalidateIssueCaches( - regionUrl: string, - orgSlug: string, - issueId: string -): Promise { - try { - await Promise.all([ - invalidateIssueDetailCaches(regionUrl, orgSlug, issueId), - invalidateOrgIssueList(regionUrl, orgSlug), - ]); - } catch { - /* best-effort: mutation already succeeded upstream */ - } -} - -/** - * Sweep every paginated variant of the org's issue-list endpoint. - * Never throws. - */ -async function invalidateOrgIssueList( - regionUrl: string, - orgSlug: string -): Promise { - try { - await invalidateCachedResponsesMatching( - buildApiUrl(regionUrl, "organizations", orgSlug, "issues") - ); - } catch { - /* best-effort: mutation already succeeded upstream */ - } -} diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index cf207f7bb..4d87ea750 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -25,8 +25,6 @@ import { cacheProjectsForOrg } from "../db/project-cache.js"; import { getCachedOrganizations } from "../db/regions.js"; import { type AuthGuardSuccess, withAuthGuard } 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"; import { buildProjectUrl } from "../sentry-urls.js"; import { isAllDigits } from "../utils.js"; @@ -34,7 +32,6 @@ import { isAllDigits } from "../utils.js"; import { API_MAX_PER_PAGE, apiRequestToRegion, - buildApiUrl, getOrgSdkConfig, MAX_PAGINATION_PAGES, ORG_FANOUT_CONCURRENCY, @@ -169,7 +166,6 @@ export async function createProject( body, }); const data = unwrapResult(result, "Failed to create project"); - await invalidateOrgProjectCache(orgSlug); return data as unknown as SentryProject; } @@ -219,52 +215,6 @@ export async function deleteProject( }, }); unwrapResult(result, "Failed to delete project"); - await invalidateProjectCaches(orgSlug, projectSlug); -} - -/** - * Flush the project-detail GET and the org-wide project list so - * follow-up `project list` / `project view` reads don't see the - * deleted project. Never throws. - * - * Uses prefix-matching on the project detail URL rather than an exact - * match because `getProject()` appends `?collapse=organization`, and - * the response cache keys entries by the full URL (including query - * string). A prefix sweep catches the collapsed variant plus any - * future query-parameter additions. - */ -async function invalidateProjectCaches( - orgSlug: string, - projectSlug: string -): Promise { - try { - const regionUrl = await resolveOrgRegion(orgSlug); - await Promise.all([ - invalidateCachedResponsesMatching( - buildApiUrl(regionUrl, "projects", orgSlug, projectSlug) - ), - invalidateCachedResponsesMatching( - buildApiUrl(regionUrl, "organizations", orgSlug, "projects") - ), - ]); - } catch { - /* best-effort: mutation already succeeded */ - } -} - -/** - * Sweep every paginated variant of the org's project-list endpoint. - * Used by `project create`. Never throws. - */ -async function invalidateOrgProjectCache(orgSlug: string): Promise { - try { - const regionUrl = await resolveOrgRegion(orgSlug); - await invalidateCachedResponsesMatching( - buildApiUrl(regionUrl, "organizations", orgSlug, "projects") - ); - } catch { - /* best-effort: mutation already succeeded */ - } } /** Result of searching for projects by slug across all organizations. */ diff --git a/src/lib/cache-keys.ts b/src/lib/cache-keys.ts new file mode 100644 index 000000000..d73b75727 --- /dev/null +++ b/src/lib/cache-keys.ts @@ -0,0 +1,159 @@ +/** + * Compute cache-invalidation prefixes for a mutation URL. + * + * The HTTP layer calls {@link computeInvalidationPrefixes} after every + * successful non-GET request and feeds the result into + * `invalidateCachedResponsesMatching`. Two rules apply: + * + * 1. **Hierarchy walk.** Sweep the URL's own path and every ancestor + * up to `/api/0/`. A mutation on + * `/organizations/{org}/releases/1.0.0/deploys/` sweeps itself, + * `.../releases/1.0.0/`, and `.../releases/` — which catches the + * detail, deploys-list, and releases-list GET caches in one pass. + * + * 2. **Cross-endpoint rules.** A small hardcoded table for mutations + * whose effects cross URL trees. For example, creating a project + * under a team hits `/organizations/{org}/teams/{team}/projects/` + * but invalidates the org project list at + * `/organizations/{org}/projects/`. The table is tiny today + * (2 rules) and only grows when a new cross-tree relationship + * appears in the API surface. + * + * Prefixes are identity-scoped at the sweep layer + * (`invalidateCachedResponsesMatching` checks `entry.identity`), so a + * slightly broader sweep is safe — it can only touch the current + * identity's entries. Query strings on the mutation URL are dropped + * from the prefix (a prefix sweep on the path naturally catches every + * query-param variant cached under that path). + */ + +/** Regex capturing the `/api/0/` boundary. Anchored so it matches only the canonical API prefix. */ +const API_V0_SEGMENT = "/api/0/"; + +/** + * Cross-endpoint invalidation rules. + * + * Each rule is a pattern the mutation URL path must match, plus a + * function that returns additional prefixes to sweep. Patterns match + * against the *path*, not the full URL — so the prefix returned is + * prepended with the request's base later. + * + * Keep this table small. The hierarchy walk handles most cases; add a + * rule here only when the API's cross-tree relationships force it. + */ +type CrossEndpointRule = { + /** Matches the path relative to `/api/0/` (no leading slash). */ + match: RegExp; + /** Returns additional path prefixes (relative to `/api/0/`) to sweep. */ + extra: (matchGroups: RegExpMatchArray) => string[]; +}; + +const CROSS_ENDPOINT_RULES: CrossEndpointRule[] = [ + { + // POST /api/0/teams/{org}/{team}/projects/ (create a project in a team) + // invalidates the org project list at + // /api/0/organizations/{org}/projects/ which lives under a + // different URL tree. + match: /^teams\/([^/]+)\/[^/]+\/projects\/?$/, + extra: ([, org]) => [`organizations/${org}/projects/`], + }, + { + // DELETE /api/0/projects/{org}/{project}/ (delete a project) + // invalidates the org project list at + // /api/0/organizations/{org}/projects/ (different URL tree). + match: /^projects\/([^/]+)\/[^/]+\/?$/, + extra: ([, org]) => [`organizations/${org}/projects/`], + }, +]; + +/** + * Compute the full set of cache-invalidation prefixes for a mutation + * URL. + * + * @param fullUrl - Fully-qualified URL of the mutation (absolute, + * including base). Query string is ignored. + * @returns Array of full-URL prefixes (including base and + * `/api/0/`) ready to pass to + * `invalidateCachedResponsesMatching`. Returns `[]` if the URL is + * not under `/api/0/` (e.g. sourcemap chunk upload to an arbitrary + * endpoint) or can't be parsed. + */ +export function computeInvalidationPrefixes(fullUrl: string): string[] { + let parsed: URL; + try { + parsed = new URL(fullUrl); + } catch { + return []; + } + + const apiIdx = parsed.pathname.indexOf(API_V0_SEGMENT); + if (apiIdx === -1) { + return []; + } + + // `base` includes origin + path up through and including `/api/0/`. + const base = `${parsed.origin}${parsed.pathname.slice(0, apiIdx + API_V0_SEGMENT.length)}`; + // Path below `/api/0/`, leading slash trimmed, trailing slash kept + // so it matches against rules that anchor on `/?$`. + const relPath = parsed.pathname.slice(apiIdx + API_V0_SEGMENT.length); + + // No relative path means the mutation hit `/api/0/` itself; nothing to sweep. + if (relPath === "") { + return []; + } + + const prefixes = new Set(); + + // Rule 1: hierarchy walk. Sweep the URL's own path plus every + // ancestor with at least one segment. + for (const segments of ancestorSegments(relPath)) { + prefixes.add(`${base}${segments}`); + } + + // Rule 2: cross-endpoint table. + for (const rule of CROSS_ENDPOINT_RULES) { + const match = relPath.match(rule.match); + if (match) { + for (const extra of rule.extra(match)) { + prefixes.add(`${base}${extra}`); + } + } + } + + return [...prefixes]; +} + +/** + * Yield every path-prefix sequence of `relPath` in descending length, + * stopping at the "resource owner" level (typically `{root}/{owner}/`, + * e.g. `organizations/acme/`). The bare `organizations/` root is + * deliberately omitted — sweeping it on every mutation would evict + * unrelated cross-org caches, since a mutation under one org cannot + * invalidate another org's state. + * + * `"organizations/acme/releases/1.0.0/deploys/"` yields: + * - `"organizations/acme/releases/1.0.0/deploys/"` + * - `"organizations/acme/releases/1.0.0/"` + * - `"organizations/acme/releases/"` + * - `"organizations/acme/"` + * + * Single-segment paths (e.g. `"organizations/"`) still yield themselves + * — a mutation at the resource-owner root is rare but the sweep should + * still clear its cache. + */ +function* ancestorSegments(relPath: string): Generator { + const trimmed = relPath.endsWith("/") ? relPath.slice(0, -1) : relPath; + if (trimmed === "") { + return; + } + const parts = trimmed.split("/"); + // Stop at 2 segments when the path has more — a mutation under + // `organizations/acme/.../...` shouldn't sweep the bare + // `organizations/` root (would evict other orgs' caches). Paths + // with ≤ 2 segments (e.g. the `organizations/` root itself, or + // `teams/acme/`) still walk all the way down. + const floor = parts.length > 2 ? 2 : 1; + for (let i = parts.length; i >= floor; i--) { + yield `${parts.slice(0, i).join("/")}/`; + } +} diff --git a/src/lib/sentry-client.ts b/src/lib/sentry-client.ts index 671275c67..5ea2b53dc 100644 --- a/src/lib/sentry-client.ts +++ b/src/lib/sentry-client.ts @@ -10,6 +10,7 @@ import { getTraceData } from "@sentry/node-core/light"; import { maybeWarnEnvTokenIgnored } from "./auth-hint.js"; +import { computeInvalidationPrefixes } from "./cache-keys.js"; import { DEFAULT_SENTRY_URL, getConfiguredSentryUrl, @@ -18,7 +19,11 @@ import { import { applyCustomHeaders } from "./custom-headers.js"; import { getAuthToken, refreshToken } from "./db/auth.js"; import { logger } from "./logger.js"; -import { getCachedResponse, storeCachedResponse } from "./response-cache.js"; +import { + getCachedResponse, + invalidateCachedResponsesMatching, + storeCachedResponse, +} from "./response-cache.js"; import { withTracingSpan } from "./telemetry.js"; const log = logger.withTag("http"); @@ -285,6 +290,35 @@ function cacheResponse( }); } +/** + * Auto-invalidate cache entries that a successful non-GET mutation + * made stale. Awaited before returning the response so a subsequent + * read in the same command sees fresh data. + * + * Prefix computation lives in {@link computeInvalidationPrefixes} + * (hierarchy walk + cross-endpoint rules). Each prefix runs through + * `invalidateCachedResponsesMatching` — a no-throw helper that's + * already identity-gated so a mutation by one account can't evict + * another account's cache. + * + * GETs skip invalidation entirely; they go through the cache-write + * path above. 4xx/5xx non-GETs skip too — a rejected mutation didn't + * change server state so its cache is still accurate. + */ +async function invalidateAfterMutation( + method: string, + fullUrl: string, + response: Response +): Promise { + if (method === "GET" || !response.ok) { + return; + } + const prefixes = computeInvalidationPrefixes(fullUrl); + await Promise.all( + prefixes.map((prefix) => invalidateCachedResponsesMatching(prefix)) + ); +} + /** Build a `{ authorization }` header map from a bearer token, or `{}` if absent. */ function authHeaders(token: string | undefined): Record { return token ? { authorization: `Bearer ${token}` } : {}; @@ -318,6 +352,7 @@ async function fetchWithRetry( authHeaders(getAuthToken()), result.response ); + await invalidateAfterMutation(method, fullUrl, result.response); return result.response; } if (result.action === "throw") { diff --git a/test/lib/cache-keys.test.ts b/test/lib/cache-keys.test.ts new file mode 100644 index 000000000..65d3e29c3 --- /dev/null +++ b/test/lib/cache-keys.test.ts @@ -0,0 +1,152 @@ +/** + * Unit tests for `computeInvalidationPrefixes`. + */ + +import { describe, expect, test } from "bun:test"; +import { computeInvalidationPrefixes } from "../../src/lib/cache-keys.js"; + +const BASE = "https://us.sentry.io/api/0/"; + +describe("computeInvalidationPrefixes — hierarchy walk", () => { + test("detail URL yields self + ancestors down to owner", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/issues/12345/` + ); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/12345/`); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/`); + expect(prefixes).toContain(`${BASE}organizations/acme/`); + // The bare `organizations/` root is deliberately NOT swept — + // it would evict other orgs' caches. + expect(prefixes).not.toContain(`${BASE}organizations/`); + }); + + test("deeply nested path yields every ancestor down to owner", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/releases/1.0.0/deploys/` + ); + expect(prefixes).toContain( + `${BASE}organizations/acme/releases/1.0.0/deploys/` + ); + expect(prefixes).toContain(`${BASE}organizations/acme/releases/1.0.0/`); + expect(prefixes).toContain(`${BASE}organizations/acme/releases/`); + expect(prefixes).toContain(`${BASE}organizations/acme/`); + // Stop at the owner level, don't sweep bare `organizations/`. + expect(prefixes).not.toContain(`${BASE}organizations/`); + }); + + test("single-segment path yields only the segment itself", () => { + // Paths with ≤ 2 segments walk all the way down — a mutation + // targeting the root itself should still clear its own cache. + const prefixes = computeInvalidationPrefixes(`${BASE}organizations/`); + expect(prefixes).toEqual([`${BASE}organizations/`]); + }); + + test("two-segment path walks to the top (owner-level mutation)", () => { + // `organizations/acme/` is the resource owner; the floor kicks in + // at length > 2, so this still yields both prefixes. + const prefixes = computeInvalidationPrefixes(`${BASE}organizations/acme/`); + expect(prefixes).toContain(`${BASE}organizations/acme/`); + expect(prefixes).toContain(`${BASE}organizations/`); + }); + + test("query string is stripped from the prefix set", () => { + // Prefix-sweep against the path catches every cached query variant, + // so the query itself is irrelevant for computing prefixes. + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/issues/12345/?collapse=stats&collapse=lifetime` + ); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/12345/`); + expect( + prefixes.every((p) => !(p.includes("collapse=") || p.includes("?"))) + ).toBe(true); + }); + + test("trailing-slashless URL still works", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/issues` + ); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/`); + expect(prefixes).toContain(`${BASE}organizations/acme/`); + }); +}); + +describe("computeInvalidationPrefixes — cross-endpoint rules", () => { + test("POST /teams/{org}/{team}/projects/ invalidates org project list", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}teams/acme/backend/projects/` + ); + expect(prefixes).toContain(`${BASE}organizations/acme/projects/`); + // Plus the hierarchy walk (capped at the owner level): + expect(prefixes).toContain(`${BASE}teams/acme/backend/projects/`); + expect(prefixes).toContain(`${BASE}teams/acme/backend/`); + expect(prefixes).toContain(`${BASE}teams/acme/`); + expect(prefixes).not.toContain(`${BASE}teams/`); + }); + + test("DELETE /projects/{org}/{project}/ invalidates org project list", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}projects/acme/frontend/` + ); + expect(prefixes).toContain(`${BASE}organizations/acme/projects/`); + expect(prefixes).toContain(`${BASE}projects/acme/frontend/`); + expect(prefixes).toContain(`${BASE}projects/acme/`); + // Two segments: floor allows the top (projects/). Reasonable — + // a DELETE on an owner-level resource does clear that root. + expect(prefixes).not.toContain(`${BASE}projects/`); + }); + + test("unrelated paths get no cross-endpoint sweep", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/teams/` + ); + // `organizations/.../teams/` doesn't match either cross-endpoint + // rule, so the result is only the hierarchy walk. + expect(prefixes).not.toContain(`${BASE}organizations/acme/projects/`); + expect(prefixes).toContain(`${BASE}organizations/acme/teams/`); + }); +}); + +describe("computeInvalidationPrefixes — edge cases", () => { + test("returns [] for URLs not under /api/0/", () => { + expect( + computeInvalidationPrefixes("https://example.com/some/path/") + ).toEqual([]); + // Chunk-upload endpoints are arbitrary host URLs. + expect( + computeInvalidationPrefixes("https://uploads.sentry.io/x/y") + ).toEqual([]); + }); + + test("returns [] for unparseable URLs", () => { + expect(computeInvalidationPrefixes("not-a-url")).toEqual([]); + expect(computeInvalidationPrefixes("")).toEqual([]); + }); + + test("returns [] when the mutation hits /api/0/ root itself", () => { + expect(computeInvalidationPrefixes(BASE)).toEqual([]); + }); + + test("deduplicates prefixes across hierarchy + rule-table", () => { + // `teams/acme/backend/projects/` matches the cross-endpoint rule + // AND naturally walks up through `teams/acme/backend/` etc. The + // rule adds `organizations/acme/projects/` (unique). No duplicates + // should appear in the result. + const prefixes = computeInvalidationPrefixes( + `${BASE}teams/acme/backend/projects/` + ); + const unique = new Set(prefixes); + expect(prefixes.length).toBe(unique.size); + }); + + test("self-hosted base URLs are preserved", () => { + const prefixes = computeInvalidationPrefixes( + "https://sentry.example.com/api/0/organizations/acme/issues/12345/" + ); + expect(prefixes).toContain( + "https://sentry.example.com/api/0/organizations/acme/issues/12345/" + ); + expect(prefixes).toContain( + "https://sentry.example.com/api/0/organizations/acme/" + ); + }); +}); diff --git a/test/lib/sentry-client.invalidation.test.ts b/test/lib/sentry-client.invalidation.test.ts new file mode 100644 index 000000000..90ff602ef --- /dev/null +++ b/test/lib/sentry-client.invalidation.test.ts @@ -0,0 +1,199 @@ +/** + * Integration tests for the HTTP-layer auto-invalidation hook. + * + * The hook lives in `sentry-client.ts` (`invalidateAfterMutation`) + * and fires after every successful non-GET at the + * `authenticatedFetch` seam. Prefix computation is delegated to + * `computeInvalidationPrefixes`; this file verifies the end-to-end + * behavior through the real response cache. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { + getCachedResponse, + storeCachedResponse, +} from "../../src/lib/response-cache.js"; +import { resetAuthenticatedFetch } from "../../src/lib/sentry-client.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("invalidation-"); + +/** + * Factory for a `Response` with a cacheable JSON body. Used both for + * priming the cache (`storeCachedResponse`) and for mock-fetch returns. + */ +function makeResponse( + body: unknown, + status = 200, + headers: Record = {} +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} + +let originalFetch: typeof globalThis.fetch; +type FetchHandler = ( + input: Request | string | URL, + init?: RequestInit +) => Promise; + +beforeEach(() => { + originalFetch = globalThis.fetch; + resetAuthenticatedFetch(); + setAuthToken("test-token", 3600, "test-refresh"); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetAuthenticatedFetch(); +}); + +/** Swap `globalThis.fetch` with a deterministic handler for one test. */ +function installMockFetch(handler: FetchHandler): void { + globalThis.fetch = ((input: Request | string | URL, init?: RequestInit) => + handler(input, init)) as typeof fetch; +} + +/** + * Run the authenticated fetch against a URL + method. Spins up the + * singleton fresh (we reset it in beforeEach) so it picks up whichever + * mock is installed. + */ +async function runAuthenticatedFetch( + url: string, + method = "GET" +): Promise { + // Import lazily so the module's cached state is reset between tests. + const { getSdkConfig } = await import("../../src/lib/sentry-client.js"); + const { fetch } = getSdkConfig("https://us.sentry.io"); + return fetch(url, { method }); +} + +const BASE = "https://us.sentry.io/api/0/"; +const DETAIL_URL = `${BASE}organizations/acme/issues/12345/`; +const LIST_URL = `${BASE}organizations/acme/issues/`; + +describe("HTTP-layer auto-invalidation", () => { + test("successful non-GET clears cached detail + list entries", async () => { + // Prime the cache as if earlier GETs populated these entries. + await storeCachedResponse( + "GET", + DETAIL_URL, + {}, + makeResponse({ id: "12345" }) + ); + await storeCachedResponse( + "GET", + `${LIST_URL}?cursor=abc`, + {}, + makeResponse({ data: [] }) + ); + expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + expect( + await getCachedResponse("GET", `${LIST_URL}?cursor=abc`, {}) + ).toBeDefined(); + + // Perform a mutation on the detail URL. + installMockFetch(async (input, init) => { + expect(init?.method).toBe("PUT"); + expect(String(input)).toBe(DETAIL_URL); + return makeResponse({ id: "12345", status: "resolved" }); + }); + const response = await runAuthenticatedFetch(DETAIL_URL, "PUT"); + expect(response.ok).toBe(true); + + // Invalidation is awaited inside the fetch hook, so by the time + // the mutation's caller sees the response, the cache is already + // cleared — no race, no sleep needed. + expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeUndefined(); + expect( + await getCachedResponse("GET", `${LIST_URL}?cursor=abc`, {}) + ).toBeUndefined(); + }); + + test("failed non-GET does NOT invalidate the cache", async () => { + await storeCachedResponse( + "GET", + DETAIL_URL, + {}, + makeResponse({ id: "12345" }) + ); + expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + + installMockFetch(async () => makeResponse({ error: "denied" }, 403)); + const response = await runAuthenticatedFetch(DETAIL_URL, "PUT"); + expect(response.status).toBe(403); + + // Cache entry survives — a rejected mutation didn't change state. + expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + }); + + test("GET does NOT invalidate the cache", async () => { + await storeCachedResponse( + "GET", + DETAIL_URL, + {}, + makeResponse({ id: "12345" }) + ); + expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + + // A fresh GET to a different URL — shouldn't touch existing cache entries. + installMockFetch(async () => makeResponse({ id: "99999" })); + await runAuthenticatedFetch( + `${BASE}organizations/acme/issues/99999/`, + "GET" + ); + + expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + }); + + test("cross-endpoint rule fires for project delete", async () => { + // Prime the org project-list cache. + const orgListUrl = `${BASE}organizations/acme/projects/`; + await storeCachedResponse( + "GET", + `${orgListUrl}?cursor=xyz`, + {}, + makeResponse({ data: [] }) + ); + expect( + await getCachedResponse("GET", `${orgListUrl}?cursor=xyz`, {}) + ).toBeDefined(); + + // DELETE on the non-org-prefixed project URL. + const deleteUrl = `${BASE}projects/acme/frontend/`; + installMockFetch(async () => makeResponse({}, 204)); + await runAuthenticatedFetch(deleteUrl, "DELETE"); + + // The cross-endpoint rule sweeps the org project-list even though + // the mutation hit a different URL tree. + expect( + await getCachedResponse("GET", `${orgListUrl}?cursor=xyz`, {}) + ).toBeUndefined(); + }); + + test("another identity's cache survives a mutation", async () => { + // Identity A caches an entry. + setAuthToken("identity-a", 3600, "refresh-a"); + await storeCachedResponse( + "GET", + DETAIL_URL, + {}, + makeResponse({ owner: "a" }) + ); + expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + + // Switch to identity B and mutate the same URL. + setAuthToken("identity-b", 3600, "refresh-b"); + installMockFetch(async () => makeResponse({}, 200)); + await runAuthenticatedFetch(DETAIL_URL, "PUT"); + + // Back as identity A: the entry must survive because invalidation + // is identity-gated. + setAuthToken("identity-a", 3600, "refresh-a"); + expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + }); +});