From 488c24f738d18f6087a4837a6cc487e4e4aecd18 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Wed, 18 Feb 2026 08:13:20 -0500 Subject: [PATCH] Add check runner: HTTP checker, cron API route, and tests Implements the monitoring heartbeat that checks all sites every 5 minutes via Vercel Cron. Includes the HTTP checker module (with timeout handling), the /api/checks/run POST endpoint (auth-gated, records checks, opens incidents on failure), vercel.json cron config, and comprehensive unit tests. Closes #7 Co-Authored-By: Claude Opus 4.6 --- src/__tests__/check-runner.test.ts | 116 +++++++++++++++++++++++++++++ src/__tests__/checker.test.ts | 65 ++++++++++++++++ src/app/api/checks/run/route.ts | 77 +++++++++++++++++++ src/lib/checker.ts | 36 +++++++++ vercel.json | 8 ++ 5 files changed, 302 insertions(+) create mode 100644 src/__tests__/check-runner.test.ts create mode 100644 src/__tests__/checker.test.ts create mode 100644 src/app/api/checks/run/route.ts create mode 100644 src/lib/checker.ts create mode 100644 vercel.json diff --git a/src/__tests__/check-runner.test.ts b/src/__tests__/check-runner.test.ts new file mode 100644 index 0000000..550146b --- /dev/null +++ b/src/__tests__/check-runner.test.ts @@ -0,0 +1,116 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +// Mock the checker module +vi.mock('@/lib/checker', () => ({ + checkSite: vi.fn(), +})) + +// Mock the admin client +const mockFrom = vi.fn() +const mockSupabase = { from: mockFrom } + +vi.mock('@/lib/supabase/admin', () => ({ + createAdminClient: () => mockSupabase, +})) + +import { POST } from '@/app/api/checks/run/route' +import { checkSite } from '@/lib/checker' + +describe('POST /api/checks/run', () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.CRON_SECRET = 'test-secret' + }) + + test('rejects requests without valid CRON_SECRET', async () => { + const request = new NextRequest('http://localhost/api/checks/run', { + method: 'POST', + headers: { authorization: 'Bearer wrong-secret' }, + }) + + const response = await POST(request) + expect(response.status).toBe(401) + }) + + test('rejects requests with no authorization header', async () => { + const request = new NextRequest('http://localhost/api/checks/run', { + method: 'POST', + }) + + const response = await POST(request) + expect(response.status).toBe(401) + }) + + test('returns empty result when no sites exist', async () => { + mockFrom.mockReturnValue({ + select: vi.fn().mockReturnValue({ + data: [], + error: null, + }), + }) + + const request = new NextRequest('http://localhost/api/checks/run', { + method: 'POST', + headers: { authorization: 'Bearer test-secret' }, + }) + + const response = await POST(request) + const body = await response.json() + expect(body).toEqual({ message: 'No sites to check', checks: 0, incidents: 0 }) + }) + + test('records checks and opens incidents for failures', async () => { + const site = { id: 'site-1', name: 'Test', url: 'https://example.com' } + + // Mock sites query + const mockSelect = vi.fn() + mockFrom.mockImplementation((table: string) => { + if (table === 'sites') { + return { select: () => ({ data: [site], error: null }) } + } + if (table === 'checks') { + return { + insert: () => ({ + select: () => ({ + single: () => ({ + data: { id: 'check-1', site_id: 'site-1', status: 'failure' }, + }), + }), + }), + } + } + if (table === 'incidents') { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + limit: () => ({ + single: () => ({ data: null }), + }), + }), + }), + }), + insert: vi.fn().mockReturnValue({ data: null, error: null }), + } + } + return { select: mockSelect } + }) + + vi.mocked(checkSite).mockResolvedValue({ + status: 'failure', + statusCode: 503, + error: 'HTTP 503', + }) + + const request = new NextRequest('http://localhost/api/checks/run', { + method: 'POST', + headers: { authorization: 'Bearer test-secret' }, + }) + + const response = await POST(request) + const body = await response.json() + expect(body.checks).toBe(1) + expect(body.incidents).toBe(1) + }) +}) diff --git a/src/__tests__/checker.test.ts b/src/__tests__/checker.test.ts new file mode 100644 index 0000000..28a3445 --- /dev/null +++ b/src/__tests__/checker.test.ts @@ -0,0 +1,65 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { checkSite } from '@/lib/checker' + +describe('checkSite', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + test('returns success for HTTP 200', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + })) + + const result = await checkSite('https://example.com') + expect(result).toEqual({ status: 'success', statusCode: 200, error: null }) + }) + + test('returns failure for HTTP 503', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 503, + })) + + const result = await checkSite('https://example.com') + expect(result).toEqual({ status: 'failure', statusCode: 503, error: 'HTTP 503' }) + }) + + test('returns failure on timeout', async () => { + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => { + const error = new Error('The operation was aborted') + error.name = 'AbortError' + return Promise.reject(error) + })) + + const result = await checkSite('https://example.com') + expect(result).toEqual({ status: 'failure', statusCode: null, error: 'Connection timeout' }) + }) + + test('returns failure on network error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue( + new Error('getaddrinfo ENOTFOUND example.com') + )) + + const result = await checkSite('https://example.com') + expect(result).toEqual({ + status: 'failure', + statusCode: null, + error: 'getaddrinfo ENOTFOUND example.com', + }) + }) + + test('returns failure on SSL error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue( + new Error('unable to verify the first certificate') + )) + + const result = await checkSite('https://example.com') + expect(result).toEqual({ + status: 'failure', + statusCode: null, + error: 'unable to verify the first certificate', + }) + }) +}) diff --git a/src/app/api/checks/run/route.ts b/src/app/api/checks/run/route.ts new file mode 100644 index 0000000..0fe636d --- /dev/null +++ b/src/app/api/checks/run/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createAdminClient } from '@/lib/supabase/admin' +import { checkSite } from '@/lib/checker' + +export const maxDuration = 60 + +export async function POST(request: NextRequest) { + const authHeader = request.headers.get('authorization') + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const supabase = createAdminClient() + + const { data: sites, error: sitesError } = await supabase + .from('sites') + .select('*') + + if (sitesError || !sites) { + return NextResponse.json({ error: 'Failed to fetch sites' }, { status: 500 }) + } + + if (sites.length === 0) { + return NextResponse.json({ message: 'No sites to check', checks: 0, incidents: 0 }) + } + + const results = await Promise.allSettled( + sites.map(async (site) => { + const result = await checkSite(site.url) + return { site, ...result } + }) + ) + + let newIncidents = 0 + + for (const result of results) { + if (result.status === 'rejected') continue + + const { site, status, statusCode, error } = result.value + + const { data: check } = await supabase + .from('checks') + .insert({ + site_id: site.id, + status, + status_code: statusCode, + error, + }) + .select() + .single() + + if (status === 'failure' && check) { + const { data: openIncident } = await supabase + .from('incidents') + .select('id') + .eq('site_id', site.id) + .eq('status', 'open') + .limit(1) + .single() + + if (!openIncident) { + await supabase.from('incidents').insert({ + site_id: site.id, + check_id: check.id, + status: 'open', + }) + newIncidents++ + } + } + } + + return NextResponse.json({ + message: 'Checks complete', + checks: results.length, + incidents: newIncidents, + }) +} diff --git a/src/lib/checker.ts b/src/lib/checker.ts new file mode 100644 index 0000000..7d2bdfb --- /dev/null +++ b/src/lib/checker.ts @@ -0,0 +1,36 @@ +export type CheckResult = { + status: 'success' | 'failure' + statusCode: number | null + error: string | null +} + +export async function checkSite(url: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 30000) + + try { + const response = await fetch(url, { + signal: controller.signal, + redirect: 'follow', + }) + + clearTimeout(timeout) + + if (response.ok) { + return { status: 'success', statusCode: response.status, error: null } + } else { + return { status: 'failure', statusCode: response.status, error: `HTTP ${response.status}` } + } + } catch (err) { + clearTimeout(timeout) + + if (err instanceof Error) { + if (err.name === 'AbortError') { + return { status: 'failure', statusCode: null, error: 'Connection timeout' } + } + return { status: 'failure', statusCode: null, error: err.message } + } + + return { status: 'failure', statusCode: null, error: 'Unknown error' } + } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..4fd3388 --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/api/checks/run", + "schedule": "*/5 * * * *" + } + ] +}