diff --git a/static/app/__mocks__/api.tsx b/static/app/__mocks__/api.tsx index f4b3c00e9c9249..90343cf6f61e60 100644 --- a/static/app/__mocks__/api.tsx +++ b/static/app/__mocks__/api.tsx @@ -7,6 +7,8 @@ const RealApi = jest.requireActual('sentry/api'); export const initApiClientErrorHandling = RealApi.initApiClientErrorHandling; export const hasProjectBeenRenamed = RealApi.hasProjectBeenRenamed; +export const isSimilarOrigin = RealApi.isSimilarOrigin; +export const resolveHostname = RealApi.resolveHostname; const respond = ( asyncDelay: AsyncDelay, diff --git a/static/app/api.analysis.md b/static/app/api.analysis.md new file mode 100644 index 00000000000000..e9cfcb2f0612ad --- /dev/null +++ b/static/app/api.analysis.md @@ -0,0 +1,728 @@ +# `static/app/api.tsx` — Complete API Reference + +Sentry's core HTTP client. Used throughout the frontend to communicate with the backend REST API. + +This document also covers the Jest mock at `static/app/__mocks__/api.tsx`. + +--- + +## Module-level: `apiNavigate` + `setApiNavigate` (lines 29–33) + +**`apiNavigate`** — module-private `ReactRouter3Navigate | null`, initially `null`. Holds a React Router navigate function so this non-React module can do client-side redirects. + +**`setApiNavigate(navigate)`** — exported setter. Called once at app bootstrap. All usages are guarded with `?.`, so if never set, redirects silently no-op. + +Edge case: calling it twice silently overwrites. + +--- + +## `Request` class (lines 35–61) + +| Property | Type | Description | +| ---------------- | ------------------- | ------------------------------------------------------------ | +| `alive` | `boolean` | `true` until `.cancel()` is called | +| `requestPromise` | `Promise` | The underlying fetch promise | +| `aborter` | `AbortController?` | Undefined if browser doesn't support it or `skipAbort: true` | + +**`constructor(requestPromise, aborter?)`** — stores both, sets `alive = true`. + +**`cancel()`** — sets `alive = false`, calls `aborter?.abort()`, emits `metric('app.api.request-abort', 1)`. If the request already settled, the abort is a no-op. + +--- + +## `ApiResult` type (lines 63–67) + +```ts +[data: Data, statusText: string | undefined, resp: ResponseMeta | undefined] +``` + +The "full" resolution type when `requestPromise` is called with `includeAllArgs: true`. + +--- + +## `ResponseMeta` type (lines 69–90) + +| Property | Type | +| ------------------- | ------------------------------------ | +| `status` | `Response['status']` | +| `statusText` | `Response['statusText']` | +| `responseJSON` | `R` | +| `responseText` | `string` | +| `getResponseHeader` | `(header: string) => string \| null` | + +Pure data shape wrapping the response for the jQuery-compat callback API. + +--- + +## `csrfSafeMethod(method?)` — internal (lines 95–98) + +Returns `true` for `GET|HEAD|OPTIONS|TRACE`. Used to decide whether to attach `X-CSRFToken`. Pure function; `undefined` input returns `false`. + +--- + +## `isSimilarOrigin(target, origin)` — exported (lines 105–126) + +Checks if two URLs share an ancestor domain (parent-child or sibling subdomains). + +**Behavior:** + +1. Parses both with `new URL`. Relative `target` is resolved against `origin`. +2. Returns `true` if either hostname `.endsWith()` the other (parent-child or exact match). +3. Otherwise strips one subdomain level from each and compares the remainder (sibling check). Returns `false` if either has < 2 segments after stripping. + +**Edge cases:** Relative paths always return `true`. Bare `localhost` returns `false` in the sibling check. `new URL()` throws if `origin` is invalid. + +--- + +## `ALLOWED_ANON_PAGES` — internal (lines 129–135) + +Array of `RegExp` for paths that don't trigger auth redirects on 401: `/accept/`, `/share/`, `/auth/login/`, `/join-request/`, `/unsubscribe/`. + +--- + +## `globalErrorHandlers` — internal (lines 140–142) + +```ts +Array<(resp: ResponseMeta, options: RequestOptions) => boolean>; +``` + +Chain-of-responsibility registry. Handlers return `true` to suppress the per-request error callback. Populated by `initApiClientErrorHandling`. + +--- + +## `initApiClientErrorHandling()` — exported (lines 144–196) + +Pushes a 401 handler into `globalErrorHandlers`. Should be called exactly once at bootstrap (no duplicate guard). + +**The handler's logic on every non-2xx response:** + +1. Skip if `resp.status !== 401` or page is in `ALLOWED_ANON_PAGES`. +2. Skip if `options.allowAuthError` is `true`. +3. Skip if `code` is `sudo-required`, `ignore`, `2fa-required`, or `app-connect-authentication-error`. +4. `sso-required` → `window.location.assign(extra.loginUrl)`. Returns `true`. +5. `member-disabled-over-limit` → `apiNavigate?.(extra.next, {replace: true})`. Returns `true`. +6. Otherwise: sets `session_expired` cookie (unless demo mode), then either navigates to `/auth/login/` (SPA) or `window.location.reload()`. Returns `true`. + +Returns `true` = skip per-request error callback. Returns `false` = let it through. + +**Side effects:** May set `session_expired` cookie, hard-redirect the browser, or trigger SPA navigation. + +**Edge cases:** + +- Called multiple times → duplicate handlers accumulate. +- `apiNavigate` not set → SPA navigation silently no-ops. +- `extra.loginUrl` or `extra.next` accessed without null-checking → `TypeError` if `extra` is `undefined`. + +--- + +## `buildRequestUrl(baseUrl, path, options)` — internal (lines 201–226) + +1. Serializes `options.query` via `qs.stringify`. On failure, captures to Sentry and re-throws. +2. Prepends `baseUrl` if `path` doesn't already contain it. +3. Calls `resolveHostname(fullUrl, options.host)` for multi-region routing. +4. Appends query string with `?` or `&` as needed. + +--- + +## `hasProjectBeenRenamed(response)` — exported (lines 234–249) + +Checks `response.responseJSON.detail.code === PROJECT_MOVED`. If so, calls `redirectToProject(slug)` and returns `true`. Otherwise returns `false`. + +Historical note: this may never fire in practice because browsers auto-follow 302 redirects. + +--- + +## `RequestCallbacks` type (lines 252–267) + +| Callback | Signature | +| ----------- | --------------------------------------------------------------- | +| `success?` | `(data: any, textStatus?: string, resp?: ResponseMeta) => void` | +| `error?` | `(...args: any[]) => void` (loosely typed) | +| `complete?` | `(resp: ResponseMeta, textStatus: string) => void` | + +--- + +## `RequestOptions` type (lines 269–308) + +Extends `RequestCallbacks` with: + +| Property | Type | Default | Description | +| ---------------- | -------------------------------------- | ------- | -------------------------------------------------------- | +| `allowAuthError` | `boolean` | `false` | Opt out of global 401 redirect handling | +| `data` | `any` | — | Body payload. JSON-stringified for non-GET, non-FormData | +| `headers` | `Record` | — | Extra headers merged over client defaults | +| `host` | `string` | — | Hostname override for hybrid-cloud routing | +| `method` | `'DELETE' \| 'GET' \| 'POST' \| 'PUT'` | — | HTTP verb | +| `preservedError` | `Error` | — | Pre-constructed error for stack trace coalescence | +| `query` | `Record` | — | Query parameters serialized onto the URL | +| `skipAbort` | `boolean` | `false` | Exclude from bulk cancellation via `client.clear()` | + +--- + +## `ClientOptions` type — internal (lines 310–323) + +| Property | Type | Default (in constructor) | +| ------------- | -------------------- | ------------------------ | +| `baseUrl` | `string` | `'/api/0'` | +| `credentials` | `RequestCredentials` | `'include'` | +| `headers` | `HeadersInit` | `Client.JSON_HEADERS` | + +--- + +## `HandleRequestErrorOptions` type — internal (lines 325–329) + +| Property | Type | Description | +| ---------------- | -------------------------- | ------------------------------------ | +| `id` | `string` | Unique request ID | +| `path` | `string` | Original API path | +| `requestOptions` | `Readonly` | Original options for potential retry | + +--- + +## `Client` class (lines 336–723) + +### Static: `Client.JSON_HEADERS` (lines 342–345) + +```ts +{ Accept: 'application/json; charset=utf-8', 'Content-Type': 'application/json' } +``` + +### Constructor (`options: ClientOptions = {}`) (lines 347–352) + +| Property | Default | +| ---------------- | --------------------- | +| `baseUrl` | `'/api/0'` | +| `headers` | `Client.JSON_HEADERS` | +| `credentials` | `'include'` | +| `activeRequests` | `{}` | + +### `wrapCallback(id, func, cleanup = false)` (lines 354–379) + +Returns a closure that: + +1. Looks up `activeRequests[id]`. +2. If `cleanup = true`, deletes the entry from `activeRequests`. +3. If `req` is missing or `alive === false`, returns early (callback suppressed). +4. If `hasProjectBeenRenamed(...args)` returns `true`, returns early (redirect handled). +5. Calls `func?.apply(req, args)`. + +**Edge cases:** + +- `func = undefined` → `func?.apply()` is a no-op. +- Called twice with `cleanup = true` → second call finds `req = undefined`, returns early. Idempotent. +- `@ts-expect-error` on line 372 suppresses a tuple-spread type error for `hasProjectBeenRenamed`. + +### `clear()` (lines 384–387) + +Calls `.cancel()` on every `Request` in `activeRequests`. Does **not** remove entries (they remain as dead references; the complete handler cleans up). + +**Side effects:** Sets `alive = false` on all requests, sends abort signals, emits `app.api.request-abort` per request. + +### `handleRequestError({id, path, requestOptions}, response, textStatus, errorThrown)` (lines 389–426) + +1. Reads `response.responseJSON.detail.code`. +2. **Sudo/superuser flow** (`SUDO_REQUIRED` or `SUPERUSER_REQUIRED`): + - Opens sudo modal via `openSudo()`. + - Modal's `retryRequest`: re-issues request via `this.requestPromise()`. On success calls `options.success`, on failure calls `options.error`. + - Modal's `onClose`: calls `options.error(response)` if retry didn't succeed. + - Returns early — per-request error callback is not called via `wrapCallback`. +3. **Normal error flow**: Wraps `options.error` via `wrapCallback` (no cleanup) and calls it with `(response, textStatus, errorThrown)`. + +**Edge cases:** + +- In the sudo retry path, callbacks bypass `wrapCallback` guards (no alive-check or project-rename check). +- Timing issue: if `onClose` fires between `requestPromise` resolving and `success` completing, `didSuccessfullyRetry` may still be `false`. + +### `request(path, options)` (lines 434–675) — **the core method** + +**Deprecated.** Use `useApiQuery` or `useMutation` with `apiOptions` instead. + +Returns a `Request` instance. + +#### Phase 1 — URL + body (lines 435–454) + +- Method defaults to `POST` if `data` exists, else `GET`. +- Calls `buildRequestUrl()` for full URL construction. +- `JSON.stringify(data)` unless GET or FormData. +- GET with data: appends as query string (jQuery compat). + +#### Phase 2 — Metrics + closures (lines 456–513) + +- `metric.mark('api-request-start-')` at start. +- `successHandler`: `metric.measure('app.api.request-success')` + `wrapCallback(id, options.success)`. +- `errorHandler`: `metric.measure('app.api.request-error')` + `handleRequestError(...)`. +- `completeHandler`: `wrapCallback(id, options.complete, true)` — the `true` deletes from `activeRequests`. + +#### Phase 3 — Fetch construction (lines 516–538) + +- `AbortController` created unless `skipAbort` or unsupported. +- Headers: `this.headers` merged with `options.headers`. +- `X-CSRFToken` added for non-safe methods to similar origins. +- `fetch(fullUrl, { method, body, headers, credentials, signal })`. + +#### Phase 4 — Response parsing (lines 543–616) + +- `response.text()` always attempted first. Failure → `ok = false`. +- JSON parse skipped for 204 and 3xx. Parse failure handling: + - AbortError → error path. + - MIME is JSON + SyntaxError → error path (`'JSON parse error'`). + - Expected JSON + non-empty non-JSON → error path (`'JSON parse error. Possibly returned HTML'`). + - Empty body on 201 → silently succeeds with `responseJSON = undefined`. +- `responseData` is `responseJSON` if content-type includes `json`, else `responseText`. + +#### Phase 5 — Dispatch (lines 618–669) + +- `ok = true` → `successHandler(resp, statusText, responseData)`. +- `ok = false` + `status === 200` → Sentry capture with fingerprint `'200 treated as error'`, tagged with endpoint and error reason (diagnostic). +- `ok = false` → runs all `globalErrorHandlers`. If any returns `true`, the per-request error callback is skipped. Otherwise `errorHandler(resp, statusText, errorReason)`. +- Always → `completeHandler(resp, statusText)`. + +#### Fetch rejection handler (line 657) + +Network failures and cancelled requests are silently swallowed by a no-op `() => {}`. + +#### `.catch` handler (lines 662–669) + +Logs to `console.error`. Captures to Sentry unless `error.name === 'AbortError'` or `error.message === 'Response is undefined'`. + +#### Side effects summary + +| Side Effect | When | +| ------------------------------------------- | ------------------------------------------------- | +| `metric.mark` | Request start | +| `metric.measure('app.api.request-success')` | On success | +| `metric.measure('app.api.request-error')` | On error | +| `metric('app.api.request-abort', 1)` | On cancel | +| `activeRequests[id] = request` | After fetch | +| `delete activeRequests[id]` | In complete handler | +| `Sentry.captureException` | 200-treated-as-error, unexpected throws | +| `openSudo` modal | On sudo/superuser required | +| `window.location.assign` | On 401 sso-required | +| `apiNavigate` | On 401 member-disabled, or session expired in SPA | +| `Cookies.set('session_expired')` | On 401 (non-demo) | + +#### Edge cases + +- **AbortError**: suppressed in `.catch`, does not go to Sentry. +- **Undefined response**: `new Error('Response is undefined')` thrown, logged, not sent to Sentry. +- **FormData body**: not JSON-stringified; passed to fetch directly. Note: default `Content-Type: application/json` header is still set from `this.headers` — callers should override. +- **GET with data**: data appended as query string, body is `undefined`. +- **`skipAbort: true`**: no `AbortController` created, but `cancel()` still sets `alive = false` (suppressing callbacks). +- **`wrapCallback` alive check**: all callbacks are suppressed if request was cancelled before response arrives. +- **`completeHandler` cleanup**: deletes from `activeRequests` even if request is dead. + +### `requestPromise(path, options)` (lines 683–723) + +**Deprecated.** Promise wrapper around `request()`. + +- Creates `preservedError = new Error('API Request Error')` synchronously for stack trace coalescence. +- Overrides `success` and `error` callbacks to resolve/reject the Promise. +- `includeAllArgs: true` → resolves with `[data, textStatus, resp]` (`ApiResult`). +- `includeAllArgs: false` (default) → resolves with just `data`. +- Rejects with `new RequestError(method, path, preservedError, resp)`. + +**Edge cases:** + +- Caller-provided `success`/`error` callbacks are silently ignored (overwritten). +- Unhandled rejections may be captured by Sentry's global handler. + +--- + +## `resolveHostname(path, hostname?)` — exported (lines 726–773) + +Routes requests to the correct silo in multi-region deployments. + +1. Reads `configLinks` (`regionUrl`, `sentryUrl`) and `systemFeatures` from `ConfigStore`. +2. If no explicit `hostname` and `system:multi-region` is enabled: + - `/_admin/` pages: skip routing (control silo handles region resolution). + - Control silo paths (via `detectControlSiloPath`): route to `sentryUrl`. + - Everything else: route to `regionUrl`. +3. **Dev-UI mode** (`window.__SENTRY_DEV_UI`): + - If hostname equals `sentryUrl`, drop it (same-origin). + - Otherwise extract subdomain from `*.sentry.io` and rewrite path to `/region//...` for webpack proxy routing. +4. If hostname is still set, prepend it to path. + +**Edge cases:** + +- Non-`*.sentry.io` hostnames in dev-ui mode are prepended directly without subdomain extraction. +- `/_admin/` bypass means admin requests always go through control silo proxy. +- If `regionUrl`/`sentryUrl` are not populated, multi-region logic is a no-op. + +--- + +## `detectControlSiloPath(path)` — internal (lines 775–787) + +1. Parses `path` with `new URL(path, 'https://sentry.io')` to strip query strings. +2. Strips leading `/` from pathname. +3. Tests against 253 compiled `RegExp` patterns from `controlsiloUrlPatterns`. + +**Patterns cover:** auth, OAuth, SAML, admin, integrations, webhooks, user management, avatars, API tokens, broadcasts, Sentry Apps, and third-party provisioning (Heroku, Vercel, Stripe, etc.). + +All patterns are anchored at `^` without a leading slash, matching the stripped pathname. + +--- + +--- + +# `static/app/__mocks__/api.tsx` — Mock API Client Reference + +Jest mock that replaces `sentry/api` in tests. Provides a mock `Client` class that intercepts `request()` calls and resolves them against a registry of mock responses instead of making real HTTP requests. + +--- + +## Re-exports from real module (lines 6–9) + +```ts +export const initApiClientErrorHandling = RealApi.initApiClientErrorHandling; +export const hasProjectBeenRenamed = RealApi.hasProjectBeenRenamed; +``` + +These two are passed through from the real `api.tsx` via `jest.requireActual`. Tests get the real global error handling and project-rename logic even when using the mock client. + +--- + +## `respond(asyncDelay, fn, ...args)` — internal helper (lines 11–26) + +| Parameter | Type | Description | +| ------------ | ------------------------------- | -------------------------------------------------------------------- | +| `asyncDelay` | `undefined \| number` | Delay in ms before calling the callback. `undefined` = synchronous. | +| `fn` | `FunctionCallback \| undefined` | The callback to invoke. If `undefined`, returns immediately (no-op). | +| `...args` | `any[]` | Arguments forwarded to `fn`. | + +**Behavior:** + +1. If `fn` is falsy, returns immediately. +2. If `asyncDelay` is a `number`, wraps the call in `setTimeout(() => fn(...args), asyncDelay)`. +3. If `asyncDelay` is `undefined`, calls `fn(...args)` synchronously. + +**Purpose:** Controls whether mock responses resolve synchronously (default) or asynchronously (to test loading states, race conditions, etc.). + +--- + +## `MatchCallable` type (line 33) + +```ts +type MatchCallable = (url: string, options: ApiNamespace.RequestOptions) => boolean; +``` + +A predicate function that receives the request URL and options and returns `true` if the request matches. Used in `ResponseType.match` arrays and by the `matchQuery`/`matchData` factories. + +--- + +## `ResponseType` interface (lines 36–55) + +Extends `ApiNamespace.ResponseMeta` with mock-specific fields: + +| Property | Type | Default (from `addMockResponse`) | Description | +| ------------------- | ------------------------ | -------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `body` | `any` | `''` | The mock response body. Can be a **value** or a **function** `(url, options) => any` for dynamic responses. | +| `callCount` | `0` | `0` | Incremented each time the mock is matched. Tracks how many times a mock was hit. | +| `headers` | `Record` | `{}` | Response headers. Used by `getResponseHeader`. | +| `host` | `string` | `''` | If non-empty, the mock only matches when `options.host` equals this value. | +| `match` | `MatchCallable[]` | `[]` | Array of predicates that **all** must return `true` for the mock to match. | +| `method` | `string` | `'GET'` | HTTP method to match against. | +| `statusCode` | `number` | `200` | The mock response status code. `>= 300` triggers the error path. | +| `url` | `string` | `''` | The URL path to match against (exact string equality). | +| `asyncDelay?` | `undefined \| number` | `Client.asyncDelay` | Per-response override for async delay. | +| `query?` | `Record` | — | Not used by matching logic directly; informational. Query matching is done via `matchQuery` in the `match` array. | +| `status` | (inherited) | `200` | Maps to `ResponseMeta.status`. | +| `statusCode` | (own) | `200` | The actual field used for status branching in `request()`. | +| `statusText` | (inherited) | `'OK'` | Maps to `ResponseMeta.statusText`. | +| `responseText` | (inherited) | `''` | | +| `responseJSON` | (inherited) | `''` | | +| `getResponseHeader` | (inherited) | key lookup into `headers` | Constructed by `addMockResponse`. | + +--- + +## `compareRecord(want, check)` — internal (lines 62–70) + +**Inputs:** Two `Record` objects. + +**Behavior:** Iterates over every key/value pair in `want`. Uses `lodash/isEqual` (deep equality) to compare each against the corresponding key in `check`. Returns `false` on the first mismatch. Returns `true` if all entries match. + +**Key detail:** Only checks keys present in `want` — extra keys in `check` are ignored. This means `matchQuery({page: '1'})` will pass even if `options.query` also has `{per_page: 25, cursor: 'abc'}`. + +--- + +## `afterEach` cleanup hook (lines 72–85) + +Runs after **every** test automatically: + +1. Checks `Client.errors` (accumulated unmocked-request errors). If any exist, logs each via `console.error`, then clears the map. +2. Calls `Client.clearMockResponses()` to reset the mock registry. + +This ensures: + +- Unmocked API calls produce visible test output (even though the error can't be thrown from within the mock). +- Mock responses don't leak between tests. + +--- + +## Mock `Client` class (lines 87–314) + +Implements `ApiNamespace.Client`. Replaces the real `Client` in all test files. + +### Instance properties + +| Property | Value | Description | +| ---------------- | -------------------------------------- | --------------------------------------------------------------------------------------- | +| `activeRequests` | `{}` | Empty record — mirrors real client interface. | +| `baseUrl` | `''` | Empty string (real client defaults to `'/api/0'`). | +| `headers` | `{ Accept: ..., 'Content-Type': ... }` | Copy/paste of `Client.JSON_HEADERS` (can't import real one due to circular dependency). | + +### Static properties + +| Property | Type | Initial | Description | +| --------------- | ----------------------- | ----------- | ---------------------------------------------------------------------------------------- | +| `mockResponses` | `MockResponse[]` | `[]` | Registry of `[ResponseType, jest.Mock]` tuples. Searched in order by `findMockResponse`. | +| `asyncDelay` | `undefined \| number` | `undefined` | Global default async delay. `undefined` = synchronous. | +| `errors` | `Record` | `{}` | Accumulates errors for unmocked requests. Logged and cleared in `afterEach`. | + +--- + +### `Client.clearMockResponses()` — static (line 109) + +Resets `Client.mockResponses` to `[]`. Called in `afterEach`. + +--- + +### `Client.matchQuery(query)` — static (lines 118–124) + +**Input:** `query: Record` — the expected query parameters. + +**Returns:** `MatchCallable` — a predicate `(_url, options) => boolean`. + +**Behavior:** Calls `compareRecord(query, options.query ?? {})`. Returns `true` if every key/value in `query` exists and deeply equals the same key in `options.query`. Extra keys in `options.query` are ignored. + +**Usage in tests:** + +```ts +MockApiClient.addMockResponse({ + url: '/api/0/issues/', + match: [MockApiClient.matchQuery({page: '1', per_page: '25'})], + body: [...], +}); +``` + +--- + +### `Client.matchData(data)` — static (lines 131–137) + +**Input:** `data: Record` — the expected request body fields. + +**Returns:** `MatchCallable` — a predicate `(_url, options) => boolean`. + +**Behavior:** Calls `compareRecord(data, options.data ?? {})`. Same partial-match semantics as `matchQuery` but against `options.data`. + +**Usage in tests:** + +```ts +MockApiClient.addMockResponse({ + url: '/api/0/issues/', + method: 'POST', + match: [MockApiClient.matchData({status: 'resolved'})], + body: {...}, +}); +``` + +--- + +### `Client.addMockResponse(response)` — static (lines 140–165) + +**Input:** `Partial` — any subset of mock response fields. All have defaults. + +**Returns:** `jest.Mock` — the mock function that records calls. Can be asserted on with `expect(mock).toHaveBeenCalledWith(url, options)`. + +**Behavior:** + +1. Creates a fresh `jest.fn()`. +2. Builds a complete `ResponseType` by merging defaults with the provided `response`: + - `host: ''`, `url: ''`, `status: 200`, `statusCode: 200`, `statusText: 'OK'` + - `responseText: ''`, `responseJSON: ''`, `body: ''`, `method: 'GET'` + - `callCount: 0`, `match: []` + - `asyncDelay`: falls back to `response.asyncDelay ?? Client.asyncDelay` + - `headers`: falls back to `response.headers ?? {}` + - `getResponseHeader`: closure that reads from `response.headers` +3. **Unshifts** (prepends) the `[ResponseType, mock]` tuple to `Client.mockResponses`. + +**Important: Insertion order.** New mocks are prepended, so the **most recently added** mock is checked first. This means you can override a general mock with a more specific one added later — the later mock will match first. + +**Edge case:** `status` and `statusCode` are both set to `200` by default, but the branching logic in `request()` only reads `statusCode`. If a caller sets `status: 500` but not `statusCode`, the request will still be treated as successful. + +--- + +### `Client.findMockResponse(url, options)` — static (lines 167–180) + +**Inputs:** + +- `url: string` — the request URL path. +- `options: Readonly` — the request options. + +**Returns:** `MockResponse | undefined` — the first matching `[ResponseType, jest.Mock]` tuple, or `undefined`. + +**Matching algorithm (evaluated in order, short-circuits on first match):** + +For each registered mock response: + +1. **Host check:** If `response.host` is non-empty and `options.host || ''` does not equal it → skip. +2. **URL check:** If `url !== response.url` → skip. **Exact string equality** — no pattern matching, no query string stripping, no normalization. +3. **Method check:** If `(options.method || 'GET') !== response.method` → skip. +4. **Custom matchers:** `response.match.every(matcher => matcher(url, options))` — all `MatchCallable` predicates must return `true`. + +If all four checks pass, the mock is returned. + +**Key behaviors:** + +- URL matching is **exact**. `/api/0/issues/` does not match `/api/0/issues` (trailing slash matters). +- Method defaults to `'GET'` if `options.method` is undefined. +- Host defaults to `''` if `options.host` is undefined. +- If `response.match` is `[]` (the default), `[].every(...)` returns `true` — no custom matchers needed. +- Because mocks are unshifted (prepended), newer mocks take priority. + +--- + +### `client.uniqueId()` — instance (line 182) + +Returns the hardcoded string `'123'`. Simplifies assertions by making request IDs deterministic. + +--- + +### `client.clear()` — instance (lines 190–192) + +Same as real client: calls `.cancel()` on all `activeRequests`. In practice, `activeRequests` is always empty in the mock because `request()` doesn't populate it. + +--- + +### `client.wrapCallback(id, func, cleanup)` — instance (lines 194–207) + +Simplified version of the real `wrapCallback`: + +1. Captures `Client.asyncDelay` at wrap time. +2. Returns a closure that: + a. Calls `RealApi.hasProjectBeenRenamed(...args)` — the **real** implementation. If it returns `true`, returns early (project-rename redirect). + b. Otherwise, calls `respond(asyncDelay, func, ...args)`. + +**Differences from real:** + +- No alive-check (no `activeRequests[id]` lookup). +- No cleanup (does not delete from `activeRequests`). +- Uses the module-level `asyncDelay` at wrap time, not at call time. + +--- + +### `client.requestPromise(path, options)` — instance (lines 209–228) + +Promise wrapper around `this.request()`, mirroring the real implementation: + +- `includeAllArgs: true` → resolves with `[data, ...args]`. +- `includeAllArgs: false` → resolves with `data`. +- On error → rejects with the error object directly (no `RequestError` wrapping like the real client). + +**Difference from real:** The real client wraps errors in `new RequestError(method, path, preservedError, resp)`. The mock rejects with the raw error response. + +--- + +### `client.request(url, options)` — instance (lines 233–309) + +The core method. Replaces real HTTP with mock response lookup. + +**Step-by-step behavior:** + +1. **Find mock:** Calls `Client.findMockResponse(url, options)`. + +2. **No mock found** (lines 238–254): + - Creates `new Error('No mocked response found for request: METHOD /url')`. + - **Stack trace manipulation:** Finds the first `.spec.` frame in the stack trace and trims everything above it. This makes the error point at the test file, not the mock internals. + - **Does NOT throw.** Instead, stores the error in `Client.errors[methodAndUrl]`. The `afterEach` hook will `console.error` it later. + - Why: Throwing would be caught by the component's own error handling (which shows user-friendly messages), making the missing-mock error invisible to the test author. The deferred logging approach ensures it's always visible. + +3. **Mock found — record the call** (line 259): + - Calls `mock(url, options)` — records the call on the `jest.fn()` so tests can assert `expect(mock).toHaveBeenCalledWith(...)`. + +4. **Resolve body** (lines 261–262): + - If `response.body` is a **function**, calls `response.body(url, options)` to compute the body dynamically. + - Otherwise uses `response.body` as-is. + +5. **Error path** (`response.statusCode >= 300`, lines 264–291): + - Increments `response.callCount`. + - Constructs an error object by creating a `RequestError` and then using `Object.assign` to bolt on extra fields: + - `status`, `responseText` (JSON-stringified body), `responseJSON` (raw body). + - Stub methods: `overrideMimeType`, `abort`, `then`, `error` — all no-ops. Remnants of the old jQuery XHR interface. + - Calls `this.handleRequestError(...)` which is the **real** `Client.prototype.handleRequestError` (line 311). This means: + - `SUDO_REQUIRED` / `SUPERUSER_REQUIRED` responses trigger the real sudo modal flow. + - Other errors are routed through `wrapCallback` → `options.error`. + +6. **Success path** (`statusCode < 300`, lines 292–305): + - Increments `response.callCount`. + - Calls `respond(response.asyncDelay, options.success, body, {}, responseMeta)`. + - The `responseMeta` passed to success is a minimal object: `{ getResponseHeader, statusCode, status }`. Notably missing: `statusText`, `responseText`, `responseJSON` — tests relying on these fields from the success callback's third argument will get `undefined`. + +7. **Complete callback** (line 308): + - Always called: `respond(response?.asyncDelay, options.complete)`. + - Called with **no arguments** (the real client passes `(resp, textStatus)`). Tests that rely on complete callback arguments will get `undefined`. + +**Differences from real `Client.request()`:** + +| Aspect | Real | Mock | +| -------------------------- | ----------------------------------------- | ---------------------------------------------------- | +| HTTP | `fetch()` call | Mock response lookup | +| URL construction | `buildRequestUrl` + `resolveHostname` | Exact string match on `url` | +| CSRF | Attaches `X-CSRFToken` header | Not applicable | +| AbortController | Created unless `skipAbort` | Not created | +| `activeRequests` tracking | Populated and cleaned up | Never populated | +| `wrapCallback` alive check | Yes | No | +| Success callback args | `(body, statusText, fullResponseMeta)` | `(body, {}, minimalMeta)` | +| Complete callback args | `(responseMeta, statusText)` | None | +| Error wrapping | Goes through full parse/dispatch pipeline | Directly constructs `RequestError` + `Object.assign` | +| `handleRequestError` | Own implementation | Delegates to **real** implementation | +| Metrics | `metric.mark`, `metric.measure` | None | +| Sentry captures | On 200-as-error, unexpected throws | None | +| `preservedError` | Created in `requestPromise` | Not created | +| Unmocked requests | N/A | Deferred `console.error` via `Client.errors` | + +--- + +### `client.handleRequestError` — instance (line 311) + +```ts +handleRequestError = RealApi.Client.prototype.handleRequestError; +``` + +Directly assigned from the real client's prototype. This means the mock uses the **real** sudo/superuser retry logic, including `openSudo()` modal and `requestPromise` retry. + +**Implication:** If a test returns a mock with `statusCode: 403` and `responseJSON.detail.code === 'sudo-required'`, the real sudo modal will be triggered. Tests must mock `openSudo` separately if they don't want this. + +--- + +## How matching works end-to-end + +When test code triggers a component that calls `api.request('/api/0/issues/', {method: 'GET', query: {page: '1'}})`: + +1. `Client.findMockResponse` iterates `mockResponses` (newest first). +2. For each, checks: host match → URL exact match → method match → all custom matchers. +3. Custom matchers like `matchQuery({page: '1'})` call `compareRecord({page: '1'}, options.query)` which uses `lodash/isEqual` per key. +4. First full match wins. +5. If no match: error stored in `Client.errors`, logged after test. + +**Matching pitfalls:** + +- URL must be exact — `/api/0/issues` vs `/api/0/issues/` will not match. +- Method defaults to `'GET'` in both the mock and the lookup, so omitting `method` works for GET requests. +- `matchQuery` and `matchData` are **partial** matchers — they only check keys you specify. To assert **no** extra keys, you'd need a custom `MatchCallable`. +- Multiple mocks for the same URL+method: the **last one added** (first in the array) wins. Override by adding a more specific mock after a general one. +- `response.body` as a function is resolved **after** matching, so the function receives the actual `(url, options)` and can return different bodies per call. + +--- + +## Error handling differences: real vs mock + +| Scenario | Real Client | Mock Client | +| -------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------- | +| Unmocked endpoint | N/A | Error stored in `Client.errors`, logged after test | +| Status >= 300 | Full response parsing, global handlers, `handleRequestError` | Constructs `RequestError`, delegates to **real** `handleRequestError` | +| Sudo required | `openSudo` modal, retry via `requestPromise` | Same (uses real `handleRequestError`) | +| Status < 300 | `wrapCallback` → alive check → project-rename check → `success` | `respond(asyncDelay, options.success, ...)` directly | +| Network failure | Silently swallowed | N/A (no network) | +| AbortError | Suppressed, not sent to Sentry | N/A (no abort support) | +| `complete` callback | Called with `(responseMeta, statusText)` | Called with **no arguments** | +| 200 treated as error | Sentry capture + error path | N/A (no response parsing) | diff --git a/static/app/main.tsx b/static/app/main.tsx index 343f3663cf1ea8..fde133078b9ced 100644 --- a/static/app/main.tsx +++ b/static/app/main.tsx @@ -17,12 +17,18 @@ import {SENTRY_RELEASE_VERSION, USE_TANSTACK_DEVTOOL} from 'sentry/constants'; import {preload} from 'sentry/router/preload'; import {RouteConfigProvider} from 'sentry/router/routeConfigContext'; import {routes} from 'sentry/router/routes'; +import {configureSentryCellFetch} from 'sentry/utils/api/sentryCellFetch'; +import {createDefaultErrorHandlers} from 'sentry/utils/api/sentryCellFetchErrorHandlers'; import {createReactRouter3Navigate} from 'sentry/utils/useNavigate'; function buildRouter() { const sentryCreateBrowserRouter = wrapCreateBrowserRouterV6(createBrowserRouter); const router = sentryCreateBrowserRouter(routes()); - setApiNavigate(createReactRouter3Navigate(router)); + const navigate = createReactRouter3Navigate(router); + setApiNavigate(navigate); + configureSentryCellFetch({ + errorHandlers: createDefaultErrorHandlers({navigate}), + }); return router; } diff --git a/static/app/sentryCellFetch.analysis.md b/static/app/sentryCellFetch.analysis.md new file mode 100644 index 00000000000000..6751091b264898 --- /dev/null +++ b/static/app/sentryCellFetch.analysis.md @@ -0,0 +1,114 @@ +# `sentryCellFetch` vs `api.requestPromise` — Behavioral Comparison + +## The Key Difference: What Happens When an Error Handler Suppresses an Error + +When a registered error handler (auth redirect, SSO, project rename, etc.) handles a non-2xx response and signals "I handled this," the two systems diverge: + +### `api.requestPromise()` — Promise Hangs Forever + +In `api.tsx` line 646–653, when a `globalErrorHandler` returns `true`: + +``` +ok = false path: + 1. successHandler is NOT called (we're in the error branch) + 2. globalErrorHandlers run — one returns true → shouldSkipErrorHandler = true + 3. errorHandler is NOT called (skipped by the guard) + 4. completeHandler IS called (but requestPromise doesn't use it) +``` + +Since `requestPromise` wraps `request()` by overriding only `success` (→ resolve) and `error` (→ reject), and neither callback fires, **the returned Promise never settles**. It hangs indefinitely. + +In practice this is "fine" because the handler is doing a hard redirect (`window.location.assign`, `window.location.reload`, or SPA navigate to `/auth/login/`), so the hanging promise is abandoned when the page navigates away. + +### `sentryCellFetch()` — Promise Resolves with `{json: undefined}` + +In `sentryCellFetch.tsx` `handleErrorResponse()` (line 192–238), when a handler suppresses an error it **returns** a value: + +```ts +// e.g., onAuthError returns true +if (responseMeta.status === 401 && errorHandlers?.onAuthError?.(responseMeta, options)) { + return {headers: buildResponseHeaders(response), json: undefined as unknown}; +} +``` + +The promise **resolves successfully** with `{headers: {...}, json: undefined}`. + +## Why This Matters + +| Scenario | `requestPromise` | `sentryCellFetch` | +| ------------------- | ---------------------------------- | -------------------------------------- | +| Auth redirect (401) | Promise hangs; page navigates away | Promise resolves with `undefined` data | +| SSO required | Promise hangs; browser redirects | Promise resolves with `undefined` data | +| Project renamed | Promise hangs; redirect happens | Promise resolves with `undefined` data | +| Member over limit | Promise hangs; SPA navigates | Promise resolves with `undefined` data | + +### Consequences for React Query + +With `sentryCellFetch` as a `queryFn`, a suppressed error means React Query sees a **successful** query that returned `undefined`. This means: + +1. **Caching**: React Query caches `undefined` as valid data. If the redirect doesn't complete before a re-render, or if the user navigates back, the cached `undefined` may be served. +2. **Loading states**: The query transitions from `pending` → `success` with `data: undefined`, so components render their "data loaded" state with no data instead of showing a loading spinner. +3. **Retries**: React Query won't retry because the query "succeeded." +4. **`onSuccess` callbacks**: Any configured `onSuccess` or dependent queries will fire with `undefined` input. + +With `requestPromise`, none of these happen because the promise never settles — React Query stays in `pending` state (showing a loading spinner) until the page navigates away. + +## Other Differences + +| Aspect | `api.requestPromise()` | `sentryCellFetch()` | +| --------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Error type | `RequestError(method, path, preservedError, resp)` | `RequestError(method, fullUrl, new Error('Request failed'), resp)` | +| Stack trace | `preservedError` created before async call — captures call site | `new Error('Request failed')` created at throw time — captures internal stack only | +| Network failure | Silently swallowed (fetch rejection handler is `() => {}`) | Propagates as thrown error | + +## Request Cancellation: `Client.clear()` vs TanStack Query's `AbortSignal` + +### `api.requestPromise` — Manual cancellation via `Client.clear()` + +The old `Client` class tracks every in-flight request in an `activeRequests` map. `Client.clear()` iterates that map and aborts all of them. Callers use this in three patterns: + +1. **Unmount cleanup** — `useApi()` calls `api.clear()` in its `useEffect` cleanup (unless `persistInFlight` is set). Legacy class components (`DeprecatedAsyncComponent`, `PluginComponentBase`, `SelectAsyncControl`) call it in `componentWillUnmount()`. +2. **"Cancel previous before new search"** — Hooks like `useTeams`, `useProjects`, and `SearchBar` call `api.clear()` before issuing a new search request, preventing race conditions where a slow earlier response overwrites a fast later one. +3. **"Cancel previous before new data fetch"** — Chart/table components (`EventsRequest`, `ReleaseSeries`, `DiscoverQuery`, etc.) clear before re-fetching. + +There is also a per-request `Request.cancel()` used only by `CursorPoller.disable()` for surgical cancellation of a single polling request. + +### `sentryCellFetch` — No equivalent needed + +`sentryCellFetch` does not maintain an `activeRequests` map and has no `clear()` or `cancel()` method. TanStack Query handles all three patterns natively: + +| Pattern | Old path (`Client`) | New path (TanStack Query) | +| ------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Unmount cleanup | `useApi()` effect cleanup → `api.clear()` | Query is automatically cancelled when the component unmounts and no other observers remain | +| Cancel stale search | Manual `api.clear()` before new fetch | Changing the `queryKey` (e.g., search term) automatically cancels the previous in-flight query and starts a new one | +| Cancel stale data fetch | Manual `api.clear()` before re-fetch | Same — `queryKey` change triggers automatic cancellation via the `AbortSignal` passed to `queryFn` | +| Surgical cancel (polling) | `request.cancel()` on a single `Request` | `queryClient.cancelQueries({queryKey})` or disabling the query | + +The `AbortSignal` that TanStack Query passes into `queryFn` (and which `sentryCellFetch` forwards to `fetch()`) is the mechanism that makes this work. When React Query decides a request is no longer needed, it aborts the signal, and the browser cancels the underlying fetch. No manual bookkeeping required. + +### Test coverage implication + +`api.spec.tsx` tests `Client.clear()` aborting active requests. The parity spec (`fetchParity.spec.tsx`) intentionally does not cover this because `sentryCellFetch` has no equivalent — the responsibility belongs to TanStack Query, not the fetch function. + +## Error Handler Registration + +### `api.requestPromise` — `initApiClientErrorHandling()` + +Pushes a single handler into `globalErrorHandlers` array. The handler covers: + +- 401 + `sso-required` → `window.location.assign(loginUrl)` +- 401 + `member-disabled-over-limit` → SPA navigate to `extra.next` +- 401 (other) → set `session_expired` cookie, reload or navigate to `/auth/login/` + +Handlers return `boolean` — `true` to suppress the per-request error callback. + +### `sentryCellFetch` — `configureSentryCellFetch()` + `createDefaultErrorHandlers()` + +Error handlers are injected via config, with named hooks: + +- `onSudoRequired` → opens sudo modal, returns `Promise` (owns the retry) +- `onProjectRenamed` → calls `redirectToProject(slug)`, returns `true` +- `onAuthError` → same logic as the old handler (anon pages, SSO, member limit, session expired) +- `onError` → generic catch-all + +Key structural difference: sudo/superuser handling lives **inside** `handleRequestError` in the old client (part of `Client` class), but is a pluggable `onSudoRequired` handler in `sentryCellFetch`. diff --git a/static/app/utils/api/__mocks__/sentryCellFetch.tsx b/static/app/utils/api/__mocks__/sentryCellFetch.tsx new file mode 100644 index 00000000000000..07a2dbea60c48e --- /dev/null +++ b/static/app/utils/api/__mocks__/sentryCellFetch.tsx @@ -0,0 +1,69 @@ +import type {QueryFunctionContext} from '@tanstack/react-query'; + +import {Client} from 'sentry/api'; +import type { + ApiQueryKey, + InfiniteApiQueryKey, + QueryKeyEndpointOptions, +} from 'sentry/utils/api/apiQueryKey'; +import type {ApiResponse, SentryCellFetchConfig} from 'sentry/utils/api/sentryCellFetch'; +import type {ParsedHeader} from 'sentry/utils/parseLinkHeader'; + +const mockClient = new Client(); + +export function configureSentryCellFetch(_config: SentryCellFetchConfig): void { + // no-op in tests +} + +function buildApiResponse( + json: T, + response: {getResponseHeader?: (key: string) => string | null} | undefined +): ApiResponse { + const hits = response?.getResponseHeader?.('X-Hits'); + const maxHits = response?.getResponseHeader?.('X-Max-Hits'); + return { + headers: { + Link: response?.getResponseHeader?.('Link') ?? undefined, + 'X-Hits': typeof hits === 'string' ? Number(hits) : undefined, + 'X-Max-Hits': typeof maxHits === 'string' ? Number(maxHits) : undefined, + }, + json, + }; +} + +export async function fetchWithUrl( + url: string, + options: QueryKeyEndpointOptions = {} +): Promise> { + const [json, , response] = await mockClient.requestPromise(url, { + includeAllArgs: true, + allowAuthError: options.allowAuthError, + host: options.host, + method: options.method ?? 'GET', + data: options.data, + query: options.query, + headers: options.headers, + }); + + return buildApiResponse(json as TQueryFnData, response); +} + +export async function sentryCellFetch( + context: QueryFunctionContext +): Promise> { + const [url, options] = context.queryKey; + return fetchWithUrl(url, options); +} + +export async function sentryCellFetchInfinite( + context: QueryFunctionContext +): Promise> { + const [url, options] = context.queryKey; + return fetchWithUrl(url, { + ...options, + query: { + ...options?.query, + cursor: context.pageParam?.cursor ?? options?.query?.cursor, + }, + }); +} diff --git a/static/app/utils/api/fetchParity.spec.tsx b/static/app/utils/api/fetchParity.spec.tsx new file mode 100644 index 00000000000000..b3a0bc6bc21bf1 --- /dev/null +++ b/static/app/utils/api/fetchParity.spec.tsx @@ -0,0 +1,463 @@ +import type {QueryClient, QueryFunctionContext} from '@tanstack/react-query'; +import Cookies from 'js-cookie'; + +import {redirectToProject} from 'sentry/actionCreators/redirectToProject'; +import {openSudo} from 'sentry/actionCreators/sudoModal'; +import {initApiClientErrorHandling, setApiNavigate} from 'sentry/api'; +import { + PROJECT_MOVED, + SUDO_REQUIRED, + SUPERUSER_REQUIRED, +} from 'sentry/constants/apiErrorCodes'; +import {apiFetch} from 'sentry/utils/api/apiFetch'; +import type {ApiQueryKey} from 'sentry/utils/api/apiQueryKey'; +import {RequestError} from 'sentry/utils/requestError/requestError'; +import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; + +import type {ApiResponse} from './sentryCellFetch'; +import {configureSentryCellFetch, sentryCellFetch} from './sentryCellFetch'; +import {createDefaultErrorHandlers} from './sentryCellFetchErrorHandlers'; + +// Use real implementations — jest.unmock ensures transitive dependencies +// (like openSudo, redirectToProject) still resolve to their jest.mock'd versions. +jest.unmock('sentry/api'); +jest.unmock('sentry/utils/api/sentryCellFetch'); + +jest.mock('sentry/actionCreators/sudoModal'); +jest.mock('sentry/actionCreators/redirectToProject'); + +type FetchFn = (context: QueryFunctionContext) => Promise; + +function url(path: string) { + return path as ApiQueryKey[0]; +} + +function makeContext( + queryKey: ApiQueryKey, + options?: {signal?: AbortSignal} +): QueryFunctionContext { + return { + queryKey, + signal: options?.signal ?? new AbortController().signal, + meta: undefined, + client: {} as QueryClient, + }; +} + +function mockFetchResponse( + body: unknown, + init?: {headers?: Record; status?: number; statusText?: string} +) { + const status = init?.status ?? 200; + const statusText = init?.statusText ?? 'OK'; + const responseHeaders = new Headers({ + 'content-type': 'application/json', + ...init?.headers, + }); + + return jest.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText, + headers: responseHeaders, + text: jest.fn().mockResolvedValue(JSON.stringify(body)), + }); +} + +/** + * Wait for a promise to settle, or return 'timeout' if it doesn't within `ms`. + * + * The old Client.requestPromise path hangs (never resolves/rejects) when + * globalErrorHandlers claims the error or hasProjectBeenRenamed short-circuits + * the callback. This helper lets us check side effects without waiting forever. + */ +async function settleOrTimeout( + promise: Promise, + ms = 50 +): Promise< + {type: 'resolved'; value: T} | {error: unknown; type: 'rejected'} | {type: 'timeout'} +> { + return Promise.race([ + promise.then( + value => ({type: 'resolved' as const, value}), + error => ({type: 'rejected' as const, error}) + ), + new Promise<{type: 'timeout'}>(resolve => + setTimeout(() => resolve({type: 'timeout' as const}), ms) + ), + ]); +} + +const navigate = jest.fn(); + +// ─── Section 1: Core parity ──────────────────────────────────────────── +// No error handlers configured. Both paths should produce identical results +// for success responses and generic errors (no special codes). +// +// The old path has baked-in handling for sudo-required (handleRequestError) +// and project-moved (hasProjectBeenRenamed in wrapCallback) that cannot be +// disabled. Those codes are avoided here and tested in Section 2. + +describe.each<{createFetch: () => FetchFn; name: string}>([ + {name: 'apiFetch (old)', createFetch: () => apiFetch}, + {name: 'sentryCellFetch (new)', createFetch: () => sentryCellFetch}, +])('core parity: $name', ({createFetch}) => { + let fetchFn: FetchFn; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest.spyOn(window, 'fetch'); + configureSentryCellFetch({}); + fetchFn = createFetch(); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + describe('successful responses', () => { + it('returns JSON body on 200', async () => { + const body = [{id: 1, name: 'test'}]; + fetchSpy.mockImplementation(mockFetchResponse(body)); + + const result = await fetchFn(makeContext([url('/projects/')])); + + expect(result.json).toEqual(body); + }); + + it('parses response headers', async () => { + fetchSpy.mockImplementation( + mockFetchResponse([], { + headers: { + Link: '; rel="next"', + 'X-Hits': '42', + 'X-Max-Hits': '1000', + }, + }) + ); + + const result = await fetchFn(makeContext([url('/items/')])); + + expect(result.headers.Link).toBe('; rel="next"'); + expect(result.headers['X-Hits']).toBe(42); + expect(result.headers['X-Max-Hits']).toBe(1000); + }); + + it('returns empty object body', async () => { + fetchSpy.mockImplementation(mockFetchResponse({})); + + const result = await fetchFn(makeContext([url('/projects/')])); + + expect(result.json).toEqual({}); + }); + + it('returns array body', async () => { + fetchSpy.mockImplementation(mockFetchResponse([{id: 1}, {id: 2}])); + + const result = await fetchFn(makeContext([url('/projects/')])); + + expect(result.json).toEqual([{id: 1}, {id: 2}]); + }); + }); + + describe('error responses', () => { + it('throws RequestError on 404', async () => { + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Not found'}, {status: 404}) + ); + + await expect(fetchFn(makeContext([url('/missing/')]))).rejects.toThrow( + RequestError + ); + }); + + it('throws RequestError on 500', async () => { + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Server error'}, {status: 500}) + ); + + await expect(fetchFn(makeContext([url('/projects/')]))).rejects.toThrow( + RequestError + ); + }); + + it('thrown error has status and responseJSON', async () => { + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Not found'}, {status: 404}) + ); + + try { + await fetchFn(makeContext([url('/missing/')])); + throw new Error('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(RequestError); + const reqErr = err as RequestError; + expect(reqErr.status).toBe(404); + expect(reqErr.responseJSON).toEqual({detail: 'Not found'}); + } + }); + + it('throws on 401 with allowAuthError', async () => { + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Unauthorized'}, {status: 401}) + ); + + await expect( + fetchFn(makeContext([url('/projects/'), {allowAuthError: true}])) + ).rejects.toThrow(RequestError); + }); + + it('throws on generic 403', async () => { + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Forbidden'}, {status: 403}) + ); + + await expect(fetchFn(makeContext([url('/projects/')]))).rejects.toThrow( + RequestError + ); + }); + }); + + describe('request construction', () => { + it('serializes query params into URL', async () => { + fetchSpy.mockImplementation(mockFetchResponse([])); + + await fetchFn( + makeContext([url('/projects/'), {query: {per_page: 25, cursor: 'abc'}}]) + ); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain('per_page=25'); + expect(calledUrl).toContain('cursor=abc'); + }); + + it('sends JSON body for POST', async () => { + fetchSpy.mockImplementation(mockFetchResponse({id: 1})); + + await fetchFn( + makeContext([url('/projects/'), {method: 'POST', data: {name: 'new'}}]) + ); + + const fetchInit = fetchSpy.mock.calls[0][1]; + expect(fetchInit.method).toBe('POST'); + expect(fetchInit.body).toBe(JSON.stringify({name: 'new'})); + }); + + it('does not send body for GET', async () => { + fetchSpy.mockImplementation(mockFetchResponse([])); + + await fetchFn(makeContext([url('/projects/')])); + + const fetchInit = fetchSpy.mock.calls[0][1]; + expect(fetchInit.body).toBeUndefined(); + }); + }); +}); + +// ─── Section 2: Error handler parity ─────────────────────────────────── +// Both paths have error handlers configured. Tests verify that the same +// side-effect functions are called with the same arguments. +// +// Architectural differences that affect promise resolution: +// Old path: globalErrorHandlers + handleRequestError + hasProjectBeenRenamed +// are baked into Client.request(). The requestPromise promise hangs when +// a handler claims the error. Navigation uses window.location directly, +// which throws in jsdom. +// New path: all handlers are injectable via configureSentryCellFetch. +// The promise always resolves or throws. Navigation uses +// testableWindowLocation (mocked in test setup). +// +// Jest module boundary note: +// jest.unmock('sentry/api') loads the real api.tsx, but its internal imports +// of openSudo/redirectToProject resolve to different mock instances than what +// the test file imports (due to __mocks__/api.tsx calling jest.requireActual +// and caching the module separately). This means: +// - Auth 401 tests work for both paths (verified via Cookies spy and injected +// navigate fn — not affected by mock identity). +// - Sudo and project-moved mock assertions only work for the new path. +// For the old path, we verify the error was intercepted (promise hangs). +// +// settleOrTimeout is used where the old path's promise hangs. +// console.error is suppressed where jsdom throws on window.location calls. + +let oldPathHandlersInitialized = false; + +describe.each<{createFetch: () => FetchFn; name: string; setupHandlers: () => void}>([ + { + name: 'apiFetch (old)', + createFetch: () => apiFetch, + setupHandlers: () => { + if (!oldPathHandlersInitialized) { + setApiNavigate(navigate); + initApiClientErrorHandling(); + oldPathHandlersInitialized = true; + } + }, + }, + { + name: 'sentryCellFetch (new)', + createFetch: () => sentryCellFetch, + setupHandlers: () => { + configureSentryCellFetch({ + errorHandlers: createDefaultErrorHandlers({navigate}), + }); + }, + }, +])('error handlers: $name', ({createFetch, setupHandlers}) => { + let fetchFn: FetchFn; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + navigate.mockReset(); + jest.mocked(openSudo).mockReset(); + jest.mocked(redirectToProject).mockReset(); + jest.mocked(testableWindowLocation.assign).mockReset(); + jest.mocked(testableWindowLocation.reload).mockReset(); + fetchSpy = jest.spyOn(window, 'fetch'); + setupHandlers(); + fetchFn = createFetch(); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + configureSentryCellFetch({}); + }); + + describe('sudo handling', () => { + it('intercepts sudo-required 403 and retries after sudo', async () => { + const retryBody = [{id: 1}]; + jest.mocked(openSudo).mockImplementation(opts => { + opts?.retryRequest?.(); + return Promise.resolve(); + }); + + fetchSpy + .mockImplementationOnce( + mockFetchResponse({detail: {code: SUDO_REQUIRED}}, {status: 403}) + ) + .mockImplementationOnce(mockFetchResponse(retryBody)); + + // New path: mock fires, retry resolves, promise resolves with retry body. + // Old path: api.tsx's openSudo is a different mock instance (see module + // boundary note above), so the mock implementation never fires. The + // bare auto-mock returns undefined, no retry happens, and the original + // requestPromise hangs — proving the error WAS intercepted. + const result = await settleOrTimeout(fetchFn(makeContext([url('/admin/')]))); + + if (result.type === 'resolved') { + expect(openSudo).toHaveBeenCalledWith( + expect.objectContaining({sudo: true, isSuperuser: false}) + ); + expect(result.value.json).toEqual(retryBody); + } else { + expect(result.type).toBe('timeout'); + } + }); + + it('intercepts superuser-required 403 and retries after sudo', async () => { + const retryBody = {id: 2}; + jest.mocked(openSudo).mockImplementation(opts => { + opts?.retryRequest?.(); + return Promise.resolve(); + }); + + fetchSpy + .mockImplementationOnce( + mockFetchResponse({detail: {code: SUPERUSER_REQUIRED}}, {status: 403}) + ) + .mockImplementationOnce(mockFetchResponse(retryBody)); + + const result = await settleOrTimeout(fetchFn(makeContext([url('/admin/')]))); + + if (result.type === 'resolved') { + expect(openSudo).toHaveBeenCalledWith( + expect.objectContaining({sudo: false, isSuperuser: true}) + ); + expect(result.value.json).toEqual(retryBody); + } else { + expect(result.type).toBe('timeout'); + } + }); + }); + + describe('project renamed', () => { + it('intercepts PROJECT_MOVED and redirects', async () => { + fetchSpy.mockImplementation( + mockFetchResponse( + {detail: {code: PROJECT_MOVED, extra: {slug: 'new-slug'}}}, + {status: 404} + ) + ); + + // Both paths intercept the error. Old path: promise hangs + // (hasProjectBeenRenamed swallows the callback). New path: resolves. + const result = await settleOrTimeout(fetchFn(makeContext([url('/old-slug/')]))); + + expect(result.type).not.toBe('rejected'); + + if (result.type === 'resolved') { + expect(redirectToProject).toHaveBeenCalledWith('new-slug'); + } + }); + }); + + describe('auth 401', () => { + it('sets session_expired cookie on generic 401', async () => { + const cookieSpy = jest.spyOn(Cookies, 'set'); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Unauthorized'}, {status: 401}) + ); + + await settleOrTimeout(fetchFn(makeContext([url('/projects/')]))); + + expect(cookieSpy).toHaveBeenCalledWith('session_expired', '1'); + cookieSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + it('skips auth handler for codes in the skip list', async () => { + const cookieSpy = jest.spyOn(Cookies, 'set'); + fetchSpy.mockImplementation( + mockFetchResponse({detail: {code: '2fa-required'}}, {status: 401}) + ); + + await expect(fetchFn(makeContext([url('/projects/')]))).rejects.toThrow( + RequestError + ); + + expect(cookieSpy).not.toHaveBeenCalledWith('session_expired', '1'); + cookieSpy.mockRestore(); + }); + + it('does not trigger auth handler when allowAuthError is set', async () => { + const cookieSpy = jest.spyOn(Cookies, 'set'); + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Unauthorized'}, {status: 401}) + ); + + await expect( + fetchFn(makeContext([url('/projects/'), {allowAuthError: true}])) + ).rejects.toThrow(RequestError); + + expect(cookieSpy).not.toHaveBeenCalledWith('session_expired', '1'); + cookieSpy.mockRestore(); + }); + + it('navigates for member-disabled-over-limit', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + fetchSpy.mockImplementation( + mockFetchResponse( + {detail: {code: 'member-disabled-over-limit', extra: {next: '/disabled/'}}}, + {status: 401} + ) + ); + + await settleOrTimeout(fetchFn(makeContext([url('/projects/')]))); + + expect(navigate).toHaveBeenCalledWith('/disabled/', {replace: true}); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/static/app/utils/api/sentryCellFetch.spec.tsx b/static/app/utils/api/sentryCellFetch.spec.tsx new file mode 100644 index 00000000000000..7d0c45ddaa7f2d --- /dev/null +++ b/static/app/utils/api/sentryCellFetch.spec.tsx @@ -0,0 +1,286 @@ +import type {QueryClient, QueryFunctionContext} from '@tanstack/react-query'; + +import { + PROJECT_MOVED, + SUDO_REQUIRED, + SUPERUSER_REQUIRED, +} from 'sentry/constants/apiErrorCodes'; +import type {ApiQueryKey} from 'sentry/utils/api/apiQueryKey'; +import {RequestError} from 'sentry/utils/requestError/requestError'; + +import { + configureSentryCellFetch, + sentryCellFetch, + sentryCellFetchInfinite, +} from './sentryCellFetch'; + +// Unmock so we test the real implementation +jest.unmock('sentry/utils/api/sentryCellFetch'); + +function url(path: string) { + return path as ApiQueryKey[0]; +} + +function makeContext( + queryKey: ApiQueryKey, + options?: {signal?: AbortSignal} +): QueryFunctionContext { + return { + queryKey, + signal: options?.signal ?? new AbortController().signal, + meta: undefined, + client: {} as QueryClient, + }; +} + +function mockFetchResponse( + body: unknown, + init?: {headers?: Record; status?: number; statusText?: string} +) { + const status = init?.status ?? 200; + const statusText = init?.statusText ?? 'OK'; + const responseHeaders = new Headers({ + 'content-type': 'application/json', + ...init?.headers, + }); + + return jest.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText, + headers: responseHeaders, + text: jest.fn().mockResolvedValue(JSON.stringify(body)), + }); +} + +describe('sentryCellFetch', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + configureSentryCellFetch({}); + fetchSpy = jest.spyOn(window, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('returns ApiResponse on successful JSON response', async () => { + const body = [{id: 1, name: 'test'}]; + fetchSpy.mockImplementation(mockFetchResponse(body)); + + const result = await sentryCellFetch(makeContext([url('/projects/')])); + + expect(result.json).toEqual(body); + expect(result.headers).toBeDefined(); + }); + + it('includes response headers in result', async () => { + fetchSpy.mockImplementation( + mockFetchResponse([], { + headers: { + Link: '; rel="next"', + 'X-Hits': '42', + 'X-Max-Hits': '1000', + }, + }) + ); + + const result = await sentryCellFetch(makeContext([url('/items/')])); + + expect(result.headers.Link).toBe('; rel="next"'); + expect(result.headers['X-Hits']).toBe(42); + expect(result.headers['X-Max-Hits']).toBe(1000); + }); + + it('serializes query params into the URL', async () => { + fetchSpy.mockImplementation(mockFetchResponse([])); + + await sentryCellFetch( + makeContext([url('/projects/'), {query: {per_page: 25, cursor: 'abc'}}]) + ); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain('per_page=25'); + expect(calledUrl).toContain('cursor=abc'); + }); + + it('sends JSON body for POST requests', async () => { + fetchSpy.mockImplementation(mockFetchResponse({id: 1})); + + await sentryCellFetch( + makeContext([url('/projects/'), {method: 'POST', data: {name: 'new'}}]) + ); + + const fetchInit = fetchSpy.mock.calls[0][1]; + expect(fetchInit.method).toBe('POST'); + expect(fetchInit.body).toBe(JSON.stringify({name: 'new'})); + }); + + it('passes context.signal to fetch', async () => { + fetchSpy.mockImplementation(mockFetchResponse([])); + const controller = new AbortController(); + + await sentryCellFetch(makeContext([url('/projects/')], {signal: controller.signal})); + + expect(fetchSpy.mock.calls[0][1].signal).toBe(controller.signal); + }); + + it('throws RequestError on non-ok response with no handlers', async () => { + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Not found'}, {status: 404, statusText: 'Not Found'}) + ); + + await expect(sentryCellFetch(makeContext([url('/missing/')]))).rejects.toThrow( + RequestError + ); + }); + + describe('error handlers', () => { + it('calls onAuthError for 401 responses', async () => { + const onAuthError = jest.fn().mockReturnValue(true); + configureSentryCellFetch({errorHandlers: {onAuthError}}); + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Unauthorized'}, {status: 401}) + ); + + const result = await sentryCellFetch(makeContext([url('/projects/')])); + + expect(onAuthError).toHaveBeenCalled(); + expect(result.json).toBeUndefined(); + }); + + it('throws when onAuthError returns false', async () => { + const onAuthError = jest.fn().mockReturnValue(false); + configureSentryCellFetch({errorHandlers: {onAuthError}}); + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Unauthorized'}, {status: 401}) + ); + + await expect(sentryCellFetch(makeContext([url('/projects/')]))).rejects.toThrow( + RequestError + ); + }); + + it('calls onSudoRequired for sudo-required errors', async () => { + const retryBody = [{id: 1}]; + const onSudoRequired = jest.fn().mockImplementation((_resp, retry) => retry()); + configureSentryCellFetch({errorHandlers: {onSudoRequired}}); + + // First call returns sudo-required, second (retry) succeeds + fetchSpy + .mockImplementationOnce( + mockFetchResponse( + {detail: {code: SUDO_REQUIRED, message: 'Sudo required'}}, + {status: 403} + ) + ) + .mockImplementationOnce(mockFetchResponse(retryBody)); + + const result = await sentryCellFetch(makeContext([url('/admin/')])); + + expect(onSudoRequired).toHaveBeenCalledTimes(1); + expect(result.json).toEqual(retryBody); + }); + + it('calls onSudoRequired for superuser-required errors', async () => { + const onSudoRequired = jest.fn().mockImplementation((_resp, retry) => retry()); + configureSentryCellFetch({errorHandlers: {onSudoRequired}}); + + fetchSpy + .mockImplementationOnce( + mockFetchResponse( + {detail: {code: SUPERUSER_REQUIRED, message: 'Superuser required'}}, + {status: 403} + ) + ) + .mockImplementationOnce(mockFetchResponse({ok: true})); + + await sentryCellFetch(makeContext([url('/admin/')])); + + expect(onSudoRequired).toHaveBeenCalledTimes(1); + }); + + it('calls onProjectRenamed for project-moved errors', async () => { + const onProjectRenamed = jest.fn().mockReturnValue(true); + configureSentryCellFetch({errorHandlers: {onProjectRenamed}}); + fetchSpy.mockImplementation( + mockFetchResponse( + {detail: {code: PROJECT_MOVED, extra: {slug: 'new-slug'}}}, + {status: 404} + ) + ); + + const result = await sentryCellFetch(makeContext([url('/projects/old-slug/')])); + + expect(onProjectRenamed).toHaveBeenCalled(); + expect(result.json).toBeUndefined(); + }); + + it('calls onError as catch-all for unhandled errors', async () => { + const onError = jest.fn().mockReturnValue(true); + configureSentryCellFetch({errorHandlers: {onError}}); + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Server error'}, {status: 500}) + ); + + const result = await sentryCellFetch(makeContext([url('/projects/')])); + + expect(onError).toHaveBeenCalled(); + expect(result.json).toBeUndefined(); + }); + + it('does not call onAuthError for non-401 errors', async () => { + const onAuthError = jest.fn().mockReturnValue(true); + const onError = jest.fn().mockReturnValue(true); + configureSentryCellFetch({errorHandlers: {onAuthError, onError}}); + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Forbidden'}, {status: 403}) + ); + + await sentryCellFetch(makeContext([url('/projects/')])); + + expect(onAuthError).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalled(); + }); + + it('named handlers take priority over onError', async () => { + const onAuthError = jest.fn().mockReturnValue(true); + const onError = jest.fn().mockReturnValue(true); + configureSentryCellFetch({errorHandlers: {onAuthError, onError}}); + fetchSpy.mockImplementation( + mockFetchResponse({detail: 'Unauthorized'}, {status: 401}) + ); + + await sentryCellFetch(makeContext([url('/projects/')])); + + expect(onAuthError).toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + }); + + describe('sentryCellFetchInfinite', () => { + it('merges cursor from pageParam into query', async () => { + fetchSpy.mockImplementation(mockFetchResponse([])); + + const context = { + queryKey: [ + url('/items/'), + {query: {per_page: 25}, method: 'GET' as const}, + {infinite: true as const}, + ] as const, + signal: new AbortController().signal, + meta: undefined, + client: {} as QueryClient, + pageParam: {cursor: 'next-cursor', href: '', results: true}, + direction: 'forward' as const, + }; + + await sentryCellFetchInfinite(context); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain('cursor=next-cursor'); + expect(calledUrl).toContain('per_page=25'); + }); + }); +}); diff --git a/static/app/utils/api/sentryCellFetch.tsx b/static/app/utils/api/sentryCellFetch.tsx new file mode 100644 index 00000000000000..89079bb2462c9f --- /dev/null +++ b/static/app/utils/api/sentryCellFetch.tsx @@ -0,0 +1,284 @@ +import type {QueryFunctionContext} from '@tanstack/react-query'; +import * as qs from 'query-string'; + +import type {ResponseMeta} from 'sentry/api'; +import {isSimilarOrigin, resolveHostname} from 'sentry/api'; +import { + PROJECT_MOVED, + SUDO_REQUIRED, + SUPERUSER_REQUIRED, +} from 'sentry/constants/apiErrorCodes'; +import type { + ApiQueryKey, + InfiniteApiQueryKey, + QueryKeyEndpointOptions, +} from 'sentry/utils/api/apiQueryKey'; +import {getCsrfToken} from 'sentry/utils/getCsrfToken'; +import type {ParsedHeader} from 'sentry/utils/parseLinkHeader'; +import {RequestError} from 'sentry/utils/requestError/requestError'; + +export type ApiResponse = { + headers: { + Link?: string; + 'X-Hits'?: number; + 'X-Max-Hits'?: number; + }; + json: TResponseData; +}; + +export interface SentryCellFetchErrorHandlers { + /** + * Called on 401 responses. Return true to suppress the error (e.g., after + * handling via redirect). Called BEFORE the generic onError handler. + */ + onAuthError?: ( + response: ResponseMeta, + requestOptions: QueryKeyEndpointOptions + ) => boolean; + + /** + * Generic catch-all error handler. Called for ALL error responses not already + * suppressed by a named handler. Return true to suppress, false to throw. + */ + onError?: (response: ResponseMeta, requestOptions: QueryKeyEndpointOptions) => boolean; + + /** + * Called when response has PROJECT_MOVED code. Return true to suppress the + * error and handle the redirect externally. + */ + onProjectRenamed?: (response: ResponseMeta) => boolean; + + /** + * Called on 403 with SUDO_REQUIRED or SUPERUSER_REQUIRED code. Receives a + * retry function that re-issues the same request. Should return a promise + * that resolves with the retry result, or rejects on failure. + */ + onSudoRequired?: ( + response: ResponseMeta, + retry: () => Promise + ) => Promise; +} + +export interface SentryCellFetchConfig { + baseUrl?: string; + credentials?: RequestCredentials; + errorHandlers?: SentryCellFetchErrorHandlers; + headers?: Record; +} + +const JSON_HEADERS: Record = { + Accept: 'application/json; charset=utf-8', + 'Content-Type': 'application/json', +}; + +let currentConfig: SentryCellFetchConfig = {}; + +export function configureSentryCellFetch(newConfig: SentryCellFetchConfig): void { + currentConfig = newConfig; +} + +function csrfSafeMethod(method: string): boolean { + return /^(GET|HEAD|OPTIONS|TRACE)$/.test(method); +} + +function buildUrl( + path: string, + query: Record | undefined, + host: string | undefined +): string { + const baseUrl = currentConfig.baseUrl ?? '/api/0'; + let fullUrl = path.includes(baseUrl) ? path : baseUrl + path; + + fullUrl = resolveHostname(fullUrl, host); + + if (query) { + const params = qs.stringify(query); + if (params) { + fullUrl += fullUrl.includes('?') ? `&${params}` : `?${params}`; + } + } + + return fullUrl; +} + +function buildResponseHeaders(response: Response): ApiResponse['headers'] { + const hits = response.headers.get('X-Hits'); + const maxHits = response.headers.get('X-Max-Hits'); + return { + Link: response.headers.get('Link') ?? undefined, + 'X-Hits': typeof hits === 'string' ? Number(hits) : undefined, + 'X-Max-Hits': typeof maxHits === 'string' ? Number(maxHits) : undefined, + }; +} + +async function executeFetch( + fullUrl: string, + options: QueryKeyEndpointOptions, + signal?: AbortSignal +): Promise { + const method = options.method ?? 'GET'; + + let body: BodyInit | undefined; + if (options.data !== undefined && method !== 'GET') { + body = JSON.stringify(options.data); + } + + const baseHeaders = currentConfig.headers ?? JSON_HEADERS; + const requestHeaders = new Headers({...baseHeaders, ...options.headers}); + + if (!csrfSafeMethod(method) && isSimilarOrigin(fullUrl, window.location.origin)) { + requestHeaders.set('X-CSRFToken', getCsrfToken()); + } + + const response = await fetch(fullUrl, { + method, + body, + headers: requestHeaders, + credentials: currentConfig.credentials ?? 'include', + signal, + }); + + // Parse response body + let responseJSON: any; + let responseText = ''; + let {ok} = response; + const {status, statusText} = response; + + try { + responseText = await response.text(); + } catch { + ok = false; + } + + const responseContentType = response.headers.get('content-type'); + const isResponseJSON = responseContentType?.includes('json'); + const isStatus3XX = status >= 300 && status < 400; + + if (status !== 204 && !isStatus3XX) { + try { + responseJSON = JSON.parse(responseText); + } catch (error: unknown) { + if (isResponseJSON && error instanceof SyntaxError) { + ok = false; + } else if ( + responseText.length > 0 && + requestHeaders.get('Accept') === JSON_HEADERS.Accept && + error instanceof SyntaxError + ) { + ok = false; + } + } + } + + const responseMeta: ResponseMeta = { + status, + statusText, + responseJSON, + responseText, + getResponseHeader: (header: string) => response.headers.get(header), + }; + + if (!ok) { + return handleErrorResponse(responseMeta, options, response, fullUrl); + } + + const responseData = isResponseJSON ? responseJSON : responseText; + return { + headers: buildResponseHeaders(response), + json: responseData, + }; +} + +async function handleErrorResponse( + responseMeta: ResponseMeta, + options: QueryKeyEndpointOptions, + response: Response, + fullUrl: string +): Promise { + const errorHandlers = currentConfig.errorHandlers; + const code = responseMeta.responseJSON?.detail?.code; + const method = options.method ?? 'GET'; + + // 1. Sudo/superuser — handler owns the retry and promise resolution + if ( + (code === SUDO_REQUIRED || code === SUPERUSER_REQUIRED) && + errorHandlers?.onSudoRequired + ) { + return errorHandlers.onSudoRequired(responseMeta, () => + executeFetch(fullUrl, options, undefined) + ); + } + + // 2. Project renamed — handler does the redirect + if (code === PROJECT_MOVED && errorHandlers?.onProjectRenamed?.(responseMeta)) { + return {headers: buildResponseHeaders(response), json: undefined as unknown}; + } + + // 3. Auth error (401) — handler does the redirect + if ( + responseMeta.status === 401 && + errorHandlers?.onAuthError?.(responseMeta, options) + ) { + return {headers: buildResponseHeaders(response), json: undefined as unknown}; + } + + // 4. Generic catch-all + if (errorHandlers?.onError?.(responseMeta, options)) { + return {headers: buildResponseHeaders(response), json: undefined as unknown}; + } + + // 5. Nothing handled it — throw + const error = new RequestError( + method, + fullUrl, + new Error('Request failed'), + responseMeta + ); + throw error; +} + +export async function fetchWithUrl( + url: string, + options: QueryKeyEndpointOptions = {}, + signal?: AbortSignal +): Promise> { + const fullUrl = buildUrl(url, options.query, options.host); + + const result = await executeFetch( + fullUrl, + { + allowAuthError: options.allowAuthError, + host: options.host, + method: options.method ?? 'GET', + data: options.data, + headers: options.headers, + }, + signal + ); + + return result as ApiResponse; +} + +export async function sentryCellFetch( + context: QueryFunctionContext +): Promise> { + const [url, options] = context.queryKey; + return fetchWithUrl(url, options, context.signal); +} + +export async function sentryCellFetchInfinite( + context: QueryFunctionContext +): Promise> { + const [url, options] = context.queryKey; + return fetchWithUrl( + url, + { + ...options, + query: { + ...options?.query, + cursor: context.pageParam?.cursor ?? options?.query?.cursor, + }, + }, + context.signal + ); +} diff --git a/static/app/utils/api/sentryCellFetchErrorHandlers.spec.tsx b/static/app/utils/api/sentryCellFetchErrorHandlers.spec.tsx new file mode 100644 index 00000000000000..6d31b1cd3a7971 --- /dev/null +++ b/static/app/utils/api/sentryCellFetchErrorHandlers.spec.tsx @@ -0,0 +1,276 @@ +import Cookies from 'js-cookie'; + +import {setWindowLocation} from 'sentry-test/utils'; + +import {redirectToProject} from 'sentry/actionCreators/redirectToProject'; +import {openSudo} from 'sentry/actionCreators/sudoModal'; +import type {ResponseMeta} from 'sentry/api'; +import type {QueryKeyEndpointOptions} from 'sentry/utils/api/apiQueryKey'; +import {RequestError} from 'sentry/utils/requestError/requestError'; +import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; + +import type {ApiResponse} from './sentryCellFetch'; +import { + createDefaultAuthErrorHandler, + createDefaultErrorHandlers, + createDefaultProjectRenamedHandler, + createDefaultSudoHandler, +} from './sentryCellFetchErrorHandlers'; + +jest.mock('sentry/actionCreators/sudoModal'); +jest.mock('sentry/actionCreators/redirectToProject'); + +function makeResponse(overrides: Partial = {}): ResponseMeta { + return { + status: 401, + statusText: 'Unauthorized', + responseJSON: undefined, + responseText: '', + getResponseHeader: jest.fn().mockReturnValue(null), + ...overrides, + }; +} + +function makeOptions( + overrides: Partial = {} +): QueryKeyEndpointOptions { + return { + method: 'GET', + ...overrides, + }; +} + +describe('createDefaultAuthErrorHandler', () => { + const navigate = jest.fn(); + let handler: ReturnType; + + beforeEach(() => { + navigate.mockReset(); + handler = createDefaultAuthErrorHandler({navigate}); + jest.mocked(testableWindowLocation.assign).mockReset(); + jest.mocked(testableWindowLocation.reload).mockReset(); + }); + + afterEach(() => { + setWindowLocation('http://localhost/'); + }); + + it('returns false for allowed anonymous pages', () => { + setWindowLocation('http://localhost/accept/123/'); + + const result = handler(makeResponse(), makeOptions()); + + expect(result).toBe(false); + }); + + it('returns false when allowAuthError is set', () => { + const result = handler(makeResponse(), makeOptions({allowAuthError: true})); + + expect(result).toBe(false); + }); + + it('returns false for sudo-required code', () => { + const response = makeResponse({ + responseJSON: {detail: {code: 'sudo-required'}}, + }); + + expect(handler(response, makeOptions())).toBe(false); + }); + + it('returns false for 2fa-required code', () => { + const response = makeResponse({ + responseJSON: {detail: {code: '2fa-required'}}, + }); + + expect(handler(response, makeOptions())).toBe(false); + }); + + it('returns false for ignore code', () => { + const response = makeResponse({ + responseJSON: {detail: {code: 'ignore'}}, + }); + + expect(handler(response, makeOptions())).toBe(false); + }); + + it('returns false for app-connect-authentication-error code', () => { + const response = makeResponse({ + responseJSON: {detail: {code: 'app-connect-authentication-error'}}, + }); + + expect(handler(response, makeOptions())).toBe(false); + }); + + it('redirects to SSO login URL for sso-required code', () => { + const response = makeResponse({ + responseJSON: { + detail: {code: 'sso-required', extra: {loginUrl: 'https://sso.example.com'}}, + }, + }); + + const result = handler(response, makeOptions()); + + expect(result).toBe(true); + expect(testableWindowLocation.assign).toHaveBeenCalledWith('https://sso.example.com'); + }); + + it('navigates for member-disabled-over-limit code', () => { + const response = makeResponse({ + responseJSON: { + detail: {code: 'member-disabled-over-limit', extra: {next: '/disabled/'}}, + }, + }); + + const result = handler(response, makeOptions()); + + expect(result).toBe(true); + expect(navigate).toHaveBeenCalledWith('/disabled/', {replace: true}); + }); + + it('sets session_expired cookie for general auth failure', () => { + const cookieSpy = jest.spyOn(Cookies, 'set'); + + const result = handler(makeResponse(), makeOptions()); + + expect(result).toBe(true); + expect(cookieSpy).toHaveBeenCalledWith('session_expired', '1'); + + cookieSpy.mockRestore(); + }); + + it('calls reload for general auth failure in non-SPA mode', () => { + handler(makeResponse(), makeOptions()); + + expect(testableWindowLocation.reload).toHaveBeenCalled(); + }); +}); + +describe('createDefaultSudoHandler', () => { + const mockedOpenSudo = jest.mocked(openSudo); + let handler: ReturnType; + + beforeEach(() => { + mockedOpenSudo.mockReset(); + handler = createDefaultSudoHandler(); + }); + + it('opens sudo modal with correct flags for sudo-required', () => { + const response = makeResponse({ + status: 403, + responseJSON: {detail: {code: 'sudo-required'}}, + }); + const retry = jest.fn(); + + handler(response, retry); + + expect(mockedOpenSudo).toHaveBeenCalledWith( + expect.objectContaining({ + sudo: true, + isSuperuser: false, + }) + ); + }); + + it('opens sudo modal with correct flags for superuser-required', () => { + const response = makeResponse({ + status: 403, + responseJSON: {detail: {code: 'superuser-required'}}, + }); + const retry = jest.fn(); + + handler(response, retry); + + expect(mockedOpenSudo).toHaveBeenCalledWith( + expect.objectContaining({ + sudo: false, + isSuperuser: true, + }) + ); + }); + + it('resolves with retry result on successful retry', async () => { + const retryResult: ApiResponse = {headers: {}, json: {id: 1}}; + const retry = jest.fn().mockResolvedValue(retryResult); + + mockedOpenSudo.mockImplementation(opts => { + opts?.retryRequest?.(); + return Promise.resolve(); + }); + + const response = makeResponse({ + status: 403, + responseJSON: {detail: {code: 'sudo-required'}}, + }); + + const result = await handler(response, retry); + + expect(retry).toHaveBeenCalledTimes(1); + expect(result).toBe(retryResult); + }); + + it('rejects when retry fails', async () => { + const retryError = new Error('Retry failed'); + const retry = jest.fn().mockRejectedValue(retryError); + + mockedOpenSudo.mockImplementation(opts => { + opts?.retryRequest?.(); + return Promise.resolve(); + }); + + const response = makeResponse({ + status: 403, + responseJSON: {detail: {code: 'sudo-required'}}, + }); + + await expect(handler(response, retry)).rejects.toBe(retryError); + }); + + it('rejects with RequestError when modal is closed without retry', async () => { + mockedOpenSudo.mockImplementation(opts => { + opts?.onClose?.(); + return Promise.resolve(); + }); + + const response = makeResponse({ + status: 403, + responseJSON: {detail: {code: 'sudo-required'}}, + }); + const retry = jest.fn(); + + await expect(handler(response, retry)).rejects.toThrow(RequestError); + expect(retry).not.toHaveBeenCalled(); + }); +}); + +describe('createDefaultProjectRenamedHandler', () => { + const mockedRedirect = jest.mocked(redirectToProject); + let handler: ReturnType; + + beforeEach(() => { + mockedRedirect.mockReset(); + handler = createDefaultProjectRenamedHandler(); + }); + + it('calls redirectToProject with the new slug', () => { + const response = makeResponse({ + responseJSON: { + detail: {code: 'project-moved', extra: {slug: 'new-project-slug'}}, + }, + }); + + const result = handler(response); + + expect(result).toBe(true); + expect(mockedRedirect).toHaveBeenCalledWith('new-project-slug'); + }); +}); + +describe('createDefaultErrorHandlers', () => { + it('returns all three handlers', () => { + const handlers = createDefaultErrorHandlers({navigate: jest.fn()}); + + expect(handlers.onAuthError).toBeInstanceOf(Function); + expect(handlers.onSudoRequired).toBeInstanceOf(Function); + expect(handlers.onProjectRenamed).toBeInstanceOf(Function); + }); +}); diff --git a/static/app/utils/api/sentryCellFetchErrorHandlers.tsx b/static/app/utils/api/sentryCellFetchErrorHandlers.tsx new file mode 100644 index 00000000000000..4ae123b756827b --- /dev/null +++ b/static/app/utils/api/sentryCellFetchErrorHandlers.tsx @@ -0,0 +1,129 @@ +import Cookies from 'js-cookie'; + +import {redirectToProject} from 'sentry/actionCreators/redirectToProject'; +import {openSudo} from 'sentry/actionCreators/sudoModal'; +import {EXPERIMENTAL_SPA} from 'sentry/constants'; +import {SUDO_REQUIRED, SUPERUSER_REQUIRED} from 'sentry/constants/apiErrorCodes'; +import {isDemoModeActive} from 'sentry/utils/demoMode'; +import {RequestError} from 'sentry/utils/requestError/requestError'; +import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; +import type {ReactRouter3Navigate} from 'sentry/utils/useNavigate'; + +import type {ApiResponse, SentryCellFetchErrorHandlers} from './sentryCellFetch'; + +const ALLOWED_ANON_PAGES = [ + /^\/accept\//, + /^\/share\//, + /^\/auth\/login\//, + /^\/join-request\//, + /^\/unsubscribe\//, +]; + +const CODES_TO_SKIP = [ + 'sudo-required', + 'ignore', + '2fa-required', + 'app-connect-authentication-error', +]; + +export interface DefaultErrorHandlerOptions { + navigate: ReactRouter3Navigate; +} + +export function createDefaultAuthErrorHandler( + options: DefaultErrorHandlerOptions +): NonNullable { + return (response, requestOptions) => { + const pageAllowsAnon = ALLOWED_ANON_PAGES.some(regex => + regex.test(window.location.pathname) + ); + if (pageAllowsAnon) { + return false; + } + + if (requestOptions.allowAuthError) { + return false; + } + + const code = response?.responseJSON?.detail?.code; + const extra = response?.responseJSON?.detail?.extra; + + if (CODES_TO_SKIP.includes(code)) { + return false; + } + + if (code === 'sso-required') { + testableWindowLocation.assign(extra.loginUrl); + return true; + } + + if (code === 'member-disabled-over-limit') { + options.navigate(extra.next, {replace: true}); + return true; + } + + if (!isDemoModeActive()) { + Cookies.set('session_expired', '1'); + } + + if (EXPERIMENTAL_SPA) { + options.navigate('/auth/login/', {replace: true}); + } else { + testableWindowLocation.reload(); + } + return true; + }; +} + +export function createDefaultSudoHandler(): NonNullable< + SentryCellFetchErrorHandlers['onSudoRequired'] +> { + return (response, retry) => { + const code = response?.responseJSON?.detail?.code; + + return new Promise((resolve, reject) => { + let didSuccessfullyRetry = false; + + openSudo({ + isSuperuser: code === SUPERUSER_REQUIRED, + sudo: code === SUDO_REQUIRED, + retryRequest: async () => { + try { + const result = await retry(); + didSuccessfullyRetry = true; + resolve(result); + } catch (err) { + reject(err instanceof Error ? err : new Error(String(err))); + } + }, + onClose: () => { + if (!didSuccessfullyRetry) { + reject( + new RequestError(undefined, '', new Error('Sudo modal closed'), response) + ); + } + }, + }); + }); + }; +} + +export function createDefaultProjectRenamedHandler(): NonNullable< + SentryCellFetchErrorHandlers['onProjectRenamed'] +> { + return response => { + const slug = response?.responseJSON?.detail?.extra?.slug; + redirectToProject(slug); + return true; + }; +} + +export function createDefaultErrorHandlers( + options: DefaultErrorHandlerOptions +): SentryCellFetchErrorHandlers { + return { + onAuthError: createDefaultAuthErrorHandler(options), + onSudoRequired: createDefaultSudoHandler(), + onProjectRenamed: createDefaultProjectRenamedHandler(), + }; +} diff --git a/static/gsAdmin/init.tsx b/static/gsAdmin/init.tsx index fb5f97d034ba82..776416def0762f 100644 --- a/static/gsAdmin/init.tsx +++ b/static/gsAdmin/init.tsx @@ -11,6 +11,8 @@ import {initializeSdk} from 'sentry/bootstrap/initializeSdk'; import {DocumentTitleManager} from 'sentry/components/sentryDocumentTitle/documentTitleManager'; import {ConfigStore} from 'sentry/stores/configStore'; import type {Config} from 'sentry/types/system'; +import {configureSentryCellFetch} from 'sentry/utils/api/sentryCellFetch'; +import {createDefaultErrorHandlers} from 'sentry/utils/api/sentryCellFetchErrorHandlers'; import {DEFAULT_QUERY_CLIENT_CONFIG} from 'sentry/utils/queryClient'; import {createReactRouter3Navigate} from 'sentry/utils/useNavigate'; @@ -30,7 +32,11 @@ const queryClient = new QueryClient(DEFAULT_QUERY_CLIENT_CONFIG); const sentryCreateBrowserRouter = wrapCreateBrowserRouterV6(createBrowserRouter); const router = sentryCreateBrowserRouter(routes); -setApiNavigate(createReactRouter3Navigate(router)); +const navigate = createReactRouter3Navigate(router); +setApiNavigate(navigate); +configureSentryCellFetch({ + errorHandlers: createDefaultErrorHandlers({navigate}), +}); export function renderApp() { const rootEl = document.getElementById('blk_router')!; diff --git a/tests/js/setup.ts b/tests/js/setup.ts index 3fa19ae6214e6d..aec6ab9adf2074 100644 --- a/tests/js/setup.ts +++ b/tests/js/setup.ts @@ -70,6 +70,7 @@ jest.mock('lodash/debounce', () => ); jest.mock('sentry/utils/recreateRoute'); jest.mock('sentry/api'); +jest.mock('sentry/utils/api/sentryCellFetch'); jest .spyOn(performanceForSentry, 'VisuallyCompleteWithData') .mockImplementation(props => props.children as ReactElement);