Files
supabase/apps/studio/lib/api/status-page.test.ts
CharisandClaude Code b2cf3693dd feat(studio): /api/status-page endpoint backed by incident.io Widget API (#50931)
## Summary

* Adds `/api/status-page` (Next route + TanStack wrapper), backed by the
[incident.io](<http://incident.io>) Widget API, annotating each item
with `visible`, `show_banner`, and (for scheduled maintenances)
`banner_lead_days`.
* Deployment-mode visibility is driven by a new
`status_page:visibility_field_ids` custom-content key.
* Widget array parsing is fault-tolerant: a malformed item in one array
is dropped and logged rather than failing the whole response, so one bad
item can't hide a real ongoing incident.
* 429s from [incident.io](<http://incident.io>) are retried with
equal-jitter exponential backoff, respecting `Retry-After`, up to 2
retries.
* Nothing consumes this endpoint yet — it replaces no existing behavior
and changes nothing user-visible. Later PRs (this is PR 1 of a stack)
wire up consumers behind the `incidentIoStatusPage` ConfigCat flag.

Part of
[FE-4057](https://linear.app/supabase/issue/FE-4057/frontend-bannerbot-reconfigured)
— see Linear for full design context.

## Test plan

- [X] `pnpm --filter studio run typecheck`
- [X] `pnpm --filter studio run lint:ratchet`
- [X] `pnpm knip --workspace apps/studio`
- [X] `pnpm test:prettier`
- [X] `pnpm --filter studio exec vitest run status-page` — 44 tests
passing, including a regression test built from a real production
[incident.io](<http://incident.io>) payload that initially failed to
parse, and a compile-time type-safety regression test for the
array-parsing helper

Co-authored-by: Claude Code
[charis@supabase.io](<mailto:charis@supabase.io>)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a status page that displays ongoing incidents and maintenance,
with visibility and banner settings based on linked incident details.
* Status page data is available through a new API endpoint, with caching
for successful responses and degraded results.
* **Bug Fixes**
* Status page data can still display when some linked incident details
are unavailable; affected results are marked as degraded.
* Improved handling of invalid widget entries so they don’t prevent
valid items from being processed.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Code <charis@supabase.io>
2026-09-28 11:36:29 -04:00

305 lines
10 KiB
TypeScript

import { http, HttpResponse } from 'msw'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { mswServer } from '@/tests/lib/msw'
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal<typeof import('common')>()
return { ...actual, IS_PLATFORM: true }
})
const WIDGET_URL = 'https://api.incident.io/widget'
const API_KEY = 'test-api-key'
const STATUS_PAGE_ID = 'test-status-page-id'
function stubHappyEnv() {
vi.stubEnv('INCIDENT_IO_WIDGET_URL', WIDGET_URL)
vi.stubEnv('INCIDENT_IO_API_KEY', API_KEY)
vi.stubEnv('INCIDENT_IO_STATUS_PAGE_ID', STATUS_PAGE_ID)
}
function widgetFixture() {
return {
page_title: 'Supabase Status',
page_url: 'https://status.supabase.com',
ongoing_incidents: [
{
id: 'incident-1',
name: 'Elevated error rates',
url: 'https://status.supabase.com/incidents/incident-1',
last_update_at: '2026-01-01T00:00:00Z',
last_update_message: 'Investigating',
status: 'investigating',
current_worst_impact: 'partial_outage',
affected_components: [
{
id: 'component-1',
name: 'API',
group_name: null,
current_status: 'partial_outage',
},
],
},
],
in_progress_maintenances: [],
scheduled_maintenances: [],
}
}
afterEach(() => {
vi.unstubAllEnvs()
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('getStatusPage', () => {
test('happy path: annotates the item from its linked response incident', async () => {
stubHappyEnv()
mswServer.use(
http.get(WIDGET_URL, () => HttpResponse.json(widgetFixture())),
http.get('*/v1/status-pages/:statusPageId/incidents/:itemId/response-incidents', () =>
HttpResponse.json({
incidents: [{ id: 'response-incident-1', linked_at: '2026-01-01T00:00:00Z' }],
})
),
http.get('*/v2/incidents/:id', () =>
HttpResponse.json({
incident: {
id: 'response-incident-1',
custom_field_entries: [],
},
})
)
)
const { getStatusPage } = await import('./status-page')
const result = await getStatusPage()
expect(result.isDegraded).toBe(false)
expect(result.data.ongoing_incidents[0]).toMatchObject({
id: 'incident-1',
visible: true,
show_banner: true,
})
})
test('link endpoint 404 → item gets default annotations, isDegraded stays false', async () => {
stubHappyEnv()
mswServer.use(
http.get(WIDGET_URL, () => HttpResponse.json(widgetFixture())),
http.get(
'*/v1/status-pages/:statusPageId/incidents/:itemId/response-incidents',
() => new HttpResponse(null, { status: 404 })
)
)
const { getStatusPage } = await import('./status-page')
const result = await getStatusPage()
expect(result.data.ongoing_incidents[0]).toMatchObject({
visible: true,
show_banner: true,
})
expect(result.isDegraded).toBe(false)
})
test('/v2/incidents/:id failing → item gets default annotations AND isDegraded true', async () => {
stubHappyEnv()
mswServer.use(
http.get(WIDGET_URL, () => HttpResponse.json(widgetFixture())),
http.get('*/v1/status-pages/:statusPageId/incidents/:itemId/response-incidents', () =>
HttpResponse.json({
incidents: [{ id: 'response-incident-1', linked_at: '2026-01-01T00:00:00Z' }],
})
),
http.get('*/v2/incidents/:id', () => new HttpResponse(null, { status: 500 }))
)
const { getStatusPage } = await import('./status-page')
const result = await getStatusPage()
expect(result.data.ongoing_incidents[0]).toMatchObject({
visible: true,
show_banner: true,
})
expect(result.isDegraded).toBe(true)
})
test('widget returns 503 → getStatusPage rejects', async () => {
stubHappyEnv()
mswServer.use(http.get(WIDGET_URL, () => new HttpResponse(null, { status: 503 })))
const { getStatusPage } = await import('./status-page')
await expect(getStatusPage()).rejects.toThrow()
})
test('missing API key / status page ID → succeeds with default annotations everywhere, isDegraded true', async () => {
vi.stubEnv('INCIDENT_IO_WIDGET_URL', WIDGET_URL)
vi.stubEnv('INCIDENT_IO_API_KEY', '')
vi.stubEnv('INCIDENT_IO_STATUS_PAGE_ID', '')
mswServer.use(http.get(WIDGET_URL, () => HttpResponse.json(widgetFixture())))
const { getStatusPage } = await import('./status-page')
const result = await getStatusPage()
expect(result.isDegraded).toBe(true)
expect(result.data.ongoing_incidents[0]).toMatchObject({
visible: true,
show_banner: true,
})
})
test('real payload: scheduled maintenance without starts_at/ends_at still parses', async () => {
stubHappyEnv()
const realPayload = {
page_title: 'Supabase',
page_url: 'https://statuspage.incident.io/supabase',
ongoing_incidents: [
{
name: '401 errors due to JWT rejections',
status: 'identified',
url: 'https://statuspage.incident.io/supabase/incidents/01KZZ19EQMSC9DNDA15ZGVY19V',
id: '01KZZ19EQMSC9DNDA15ZGVY19V',
current_worst_impact: 'degraded_performance',
affected_components: [
{
id: '01KJZVWMNKQ0HV8DXHRSSERV5T',
name: 'API Gateway',
current_status: 'degraded_performance',
},
],
last_update_at: '2026-09-25T16:23:02.23Z',
last_update_message:
'The deployment process changes have been rolled out across all regions. We will monitor throughout the weekend.',
},
],
in_progress_maintenances: [],
scheduled_maintenances: [
{
id: '01KWA5M6EAYR6F74N18REZNYQ1',
name: 'Scheduled Platform Maintenance 07/02',
status: 'maintenance_scheduled',
url: 'https://statuspage.incident.io/supabase/incidents/01KWA5M6EAYR6F74N18REZNYQ1',
affected_components: [],
last_update_at: '2026-06-29T16:56:35.249Z',
last_update_message: 'We will be carrying out a scheduled maintenance window...',
},
],
}
mswServer.use(
http.get(WIDGET_URL, () => HttpResponse.json(realPayload)),
http.get(
'*/v1/status-pages/:statusPageId/incidents/:itemId/response-incidents',
() => new HttpResponse(null, { status: 404 })
)
)
const { getStatusPage } = await import('./status-page')
const result = await getStatusPage()
expect(result.isDegraded).toBe(false)
expect(result.data.ongoing_incidents).toHaveLength(1)
expect(result.data.scheduled_maintenances).toHaveLength(1)
expect(result.data.scheduled_maintenances[0]).toMatchObject({
id: '01KWA5M6EAYR6F74N18REZNYQ1',
visible: true,
show_banner: true,
banner_lead_days: null,
})
expect(result.data.scheduled_maintenances[0].starts_at).toBeUndefined()
expect(result.data.scheduled_maintenances[0].ends_at).toBeUndefined()
})
})
describe('fetchWithRetry backoff (via getStatusPage widget fetch)', () => {
test('a Retry-After that parses to 0 does not skip the equal-jitter backoff floor', async () => {
stubHappyEnv()
vi.useFakeTimers()
// Equal jitter is `base / 2 + random() * (base / 2)`; pinning random() to 0 collapses it to
// the deterministic floor (base / 2) so the wait boundary below is exact, not probabilistic.
vi.spyOn(Math, 'random').mockReturnValue(0)
let widgetCallCount = 0
mswServer.use(
http.get(WIDGET_URL, () => {
widgetCallCount++
if (widgetCallCount === 1) {
return new HttpResponse(null, {
status: 429,
headers: { 'Retry-After': new Date(Date.now() - 10_000).toUTCString() },
})
}
return HttpResponse.json(widgetFixture())
}),
http.get(
'*/v1/status-pages/:statusPageId/incidents/:itemId/response-incidents',
() => new HttpResponse(null, { status: 404 })
)
)
const { getStatusPage } = await import('./status-page')
const resultPromise = getStatusPage()
await vi.advanceTimersByTimeAsync(0)
expect(widgetCallCount).toBe(1)
// Backoff floor for the first retry (attempt 0) is (1000 * 2 ** 0) / 2 = 500ms with
// Math.random() pinned to 0.
await vi.advanceTimersByTimeAsync(499)
expect(widgetCallCount).toBe(1)
// Crossing the floor triggers the retry.
await vi.advanceTimersByTimeAsync(1)
expect(widgetCallCount).toBe(2)
const result = await resultPromise
expect(result.data.ongoing_incidents[0].id).toBe('incident-1')
})
})
describe('GET /api/status-page route Cache-Control header', () => {
test('success → 300s cache with 300s stale-while-revalidate', async () => {
stubHappyEnv()
mswServer.use(
http.get(WIDGET_URL, () => HttpResponse.json(widgetFixture())),
http.get(
'*/v1/status-pages/:statusPageId/incidents/:itemId/response-incidents',
() => new HttpResponse(null, { status: 404 })
)
)
const { GET } = await import('../../app/api/status-page/route')
const response = await GET()
expect(response.headers.get('Cache-Control')).toBe(
'public, s-maxage=300, stale-while-revalidate=300'
)
})
test('degraded (missing config) → 60s cache with 60s stale-while-revalidate', async () => {
vi.stubEnv('INCIDENT_IO_WIDGET_URL', WIDGET_URL)
vi.stubEnv('INCIDENT_IO_API_KEY', '')
vi.stubEnv('INCIDENT_IO_STATUS_PAGE_ID', '')
mswServer.use(http.get(WIDGET_URL, () => HttpResponse.json(widgetFixture())))
const { GET } = await import('../../app/api/status-page/route')
const response = await GET()
expect(response.headers.get('Cache-Control')).toBe(
'public, s-maxage=60, stale-while-revalidate=60'
)
})
test('error (widget 503) → no-store', async () => {
stubHappyEnv()
mswServer.use(http.get(WIDGET_URL, () => new HttpResponse(null, { status: 503 })))
const { GET } = await import('../../app/api/status-page/route')
const response = await GET()
expect(response.headers.get('Cache-Control')).toBe('no-store')
})
})