diff --git a/README.md b/README.md index 6832e17..ad9b5cc 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ __ ,--~' ~~----____ ## Features -- HTTP, TCP, and DNS monitoring. +- HTTP/HTTPs, TCP, and DNS monitoring. - Regional monitoring from specific Cloudflare locations. - Configurable retries with immediate retry on failure. - Configurable failure thresholds before alerting. @@ -159,6 +159,7 @@ Each monitor can override the global default\_\* settings: target: 'https://api.example.com/health' method: GET expected_status: 200 + expected_body_contains: 'OK' # Optional: check if response body contains this text headers: # optional, merged with default User-Agent: atalaya-uptime Authorization: 'Bearer ${API_TOKEN}' Accept: 'application/json' @@ -364,7 +365,7 @@ npm run check:pages # pages (astro check + tsc) ## TODO -- [ ] Add support for TLS checks (certificate validity, expiration). Apparently, the Workers API does not support certificate data access, even at the socket level. An external service may be required. +- [ ] Add support for TLS checks (certificate validity, expiration). Apparently, the Workers API does not support certificate data access, even at the socket level. An external service may be required. - [ ] Refine the status page to look... well... less IA generated. - [ ] Initial support for incident management (manual status overrides, incident timeline). - [x] Branded status page (simple custom banner). diff --git a/src/alert/webhook.test.ts b/src/alert/webhook.test.ts index 70ff9e1..9fb300a 100644 --- a/src/alert/webhook.test.ts +++ b/src/alert/webhook.test.ts @@ -29,6 +29,7 @@ describe('formatWebhookPayload', () => { method: 'GET', expectedStatus: 200, headers: {}, + expectedBodyContains: '', timeoutMs: 5000, retries: 3, retryDelayMs: 1000, @@ -105,6 +106,7 @@ describe('formatWebhookPayload', () => { method: 'GET', expectedStatus: 200, headers: {}, + expectedBodyContains: '', timeoutMs: 5000, retries: 3, retryDelayMs: 1000, diff --git a/src/checker/checker.test.ts b/src/checker/checker.test.ts index a0c36ee..ef367a2 100644 --- a/src/checker/checker.test.ts +++ b/src/checker/checker.test.ts @@ -20,6 +20,7 @@ describe('prepareChecks', () => { target: 'https://example.com', method: 'GET', expectedStatus: 200, + expectedBodyContains: '', headers: {}, timeoutMs: 5000, retries: 3, @@ -103,6 +104,7 @@ describe('prepareChecks', () => { target: 'https://example.com', method: 'GET', expectedStatus: 200, + expectedBodyContains: '', headers: { Authorization: 'Bearer token' }, timeoutMs: 5000, retries: 3, @@ -135,6 +137,7 @@ describe('prepareChecks', () => { target: 'https://example.com', method: 'GET', expectedStatus: 200, + expectedBodyContains: '', headers: {}, timeoutMs: 5000, retries: 3, diff --git a/src/checker/checker.ts b/src/checker/checker.ts index 96e059a..6d576de 100644 --- a/src/checker/checker.ts +++ b/src/checker/checker.ts @@ -19,6 +19,7 @@ export function prepareChecks(config: Config): CheckRequest[] { type: m.type, method: m.method || undefined, expectedStatus: m.expectedStatus || undefined, + expectedBodyContains: m.expectedBodyContains || undefined, headers: Object.keys(m.headers).length > 0 ? m.headers : undefined, }; } diff --git a/src/checks/http.test.ts b/src/checks/http.test.ts index a567ccd..c14676f 100644 --- a/src/checks/http.test.ts +++ b/src/checks/http.test.ts @@ -25,6 +25,7 @@ describe('executeHttpCheck', () => { vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200, + text: vi.fn().mockResolvedValue(''), } as unknown as Response); const check = createCheckRequest(); @@ -41,6 +42,7 @@ describe('executeHttpCheck', () => { vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200, + text: vi.fn().mockResolvedValue(''), } as unknown as Response); const check = createCheckRequest({ expectedStatus: 201 }); @@ -54,6 +56,7 @@ describe('executeHttpCheck', () => { vi.mocked(fetch).mockResolvedValue({ ok: true, status: 201, + text: vi.fn().mockResolvedValue(''), } as unknown as Response); const check = createCheckRequest({ expectedStatus: 201 }); @@ -76,7 +79,11 @@ describe('executeHttpCheck', () => { it('retries on failure and eventually succeeds', async () => { vi.mocked(fetch) .mockRejectedValueOnce(new Error('Network error')) - .mockResolvedValueOnce({ ok: true, status: 200 } as unknown as Response); + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue(''), + } as unknown as Response); const check = createCheckRequest({ retries: 2, retryDelayMs: 10 }); const result = await executeHttpCheck(check); @@ -86,7 +93,11 @@ describe('executeHttpCheck', () => { }); it('uses correct HTTP method', async () => { - vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue(''), + } as unknown as Response); const check = createCheckRequest({ method: 'POST' }); await executeHttpCheck(check); @@ -98,7 +109,11 @@ describe('executeHttpCheck', () => { }); it('defaults to GET method', async () => { - vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue(''), + } as unknown as Response); const check = createCheckRequest(); await executeHttpCheck(check); @@ -110,7 +125,11 @@ describe('executeHttpCheck', () => { }); it('sends default User-Agent header', async () => { - vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue(''), + } as unknown as Response); const check = createCheckRequest(); await executeHttpCheck(check); @@ -124,7 +143,11 @@ describe('executeHttpCheck', () => { }); it('merges custom headers with defaults', async () => { - vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue(''), + } as unknown as Response); const check = createCheckRequest({ headers: { Authorization: 'Bearer token123' }, @@ -137,7 +160,11 @@ describe('executeHttpCheck', () => { }); it('allows monitor headers to override defaults', async () => { - vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue(''), + } as unknown as Response); const check = createCheckRequest({ headers: { 'User-Agent': 'custom-agent' }, @@ -166,8 +193,16 @@ describe('executeHttpCheck', () => { it('retries on wrong status code', async () => { vi.mocked(fetch) - .mockResolvedValueOnce({ ok: false, status: 500 } as unknown as Response) - .mockResolvedValueOnce({ ok: true, status: 200 } as unknown as Response); + .mockResolvedValueOnce({ + ok: false, + status: 500, + text: vi.fn().mockResolvedValue(''), + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue(''), + } as unknown as Response); const check = createCheckRequest({ expectedStatus: 200, retries: 2, retryDelayMs: 10 }); const result = await executeHttpCheck(check); @@ -185,4 +220,159 @@ describe('executeHttpCheck', () => { expect(result.status).toBe('down'); expect(result.error).toBe('Unknown error'); }); + + it('returns up when body contains expected content', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue('Welcome to our site'), + } as unknown as Response); + + const check = createCheckRequest({ expectedBodyContains: 'Welcome' }); + const result = await executeHttpCheck(check); + + expect(result.status).toBe('up'); + expect(result.error).toBe(''); + }); + + it('returns down when body does not contain expected content', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue('Error: Not found'), + } as unknown as Response); + + const check = createCheckRequest({ expectedBodyContains: 'Welcome' }); + const result = await executeHttpCheck(check); + + expect(result.status).toBe('down'); + expect(result.error).toContain("Expected body to contain 'Welcome'"); + }); + + it('returns up when both status and body match', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 201, + text: vi.fn().mockResolvedValue('User created successfully'), + } as unknown as Response); + + const check = createCheckRequest({ expectedStatus: 201, expectedBodyContains: 'created' }); + const result = await executeHttpCheck(check); + + expect(result.status).toBe('up'); + expect(result.error).toBe(''); + }); + + it('returns down when status matches but body does not', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 201, + text: vi.fn().mockResolvedValue('Error: Invalid input'), + } as unknown as Response); + + const check = createCheckRequest({ expectedStatus: 201, expectedBodyContains: 'created' }); + const result = await executeHttpCheck(check); + + expect(result.status).toBe('down'); + expect(result.error).toContain("Expected body to contain 'created'"); + }); + + it('returns down when body matches but status does not', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue('User created successfully'), + } as unknown as Response); + + const check = createCheckRequest({ expectedStatus: 201, expectedBodyContains: 'created' }); + const result = await executeHttpCheck(check); + + expect(result.status).toBe('down'); + expect(result.error).toContain('Expected status 201, got 200'); + }); + + it('returns up when expected body is empty string (no body check)', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue(''), + } as unknown as Response); + + const check = createCheckRequest({ expectedBodyContains: '' }); + const result = await executeHttpCheck(check); + + expect(result.status).toBe('up'); + expect(result.error).toBe(''); + }); + + it('returns up when expected body is whitespace only (no body check)', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue('Some content'), + } as unknown as Response); + + const check = createCheckRequest({ expectedBodyContains: ' ' }); + const result = await executeHttpCheck(check); + + expect(result.status).toBe('up'); + expect(result.error).toBe(''); + }); + + it('performs case-sensitive body matching', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue('HELLO WORLD'), + } as unknown as Response); + + const check = createCheckRequest({ expectedBodyContains: 'hello' }); + const result = await executeHttpCheck(check); + + expect(result.status).toBe('down'); + expect(result.error).toContain("Expected body to contain 'hello'"); + }); + + it('retries on body mismatch and eventually succeeds', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue('Loading...'), + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue('Ready'), + } as unknown as Response); + + const check = createCheckRequest({ + expectedBodyContains: 'Ready', + retries: 2, + retryDelayMs: 10, + }); + const result = await executeHttpCheck(check); + + expect(result.status).toBe('up'); + expect(result.attempts).toBe(2); + }); + + it('retries on body mismatch and eventually fails', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue('Error'), + } as unknown as Response); + + const check = createCheckRequest({ + expectedBodyContains: 'Ready', + retries: 2, + retryDelayMs: 10, + }); + const result = await executeHttpCheck(check); + + expect(result.status).toBe('down'); + expect(result.error).toContain("Expected body to contain 'Ready'"); + expect(result.attempts).toBe(3); + }); }); diff --git a/src/checks/http.ts b/src/checks/http.ts index bd02128..204c591 100644 --- a/src/checks/http.ts +++ b/src/checks/http.ts @@ -31,28 +31,52 @@ export async function executeHttpCheck(check: HttpCheckRequest): Promise 0) { + if (response.status !== check.expectedStatus) { + statusOk = false; + errorMessages.push(`Expected status ${check.expectedStatus}, got ${response.status}`); } + } else if (response.status < 200 || response.status >= 400) { + statusOk = false; + errorMessages.push(`Expected 2xx/3xx status, got ${response.status}`); + } + if ( + check.expectedBodyContains && + check.expectedBodyContains.trim() && + !bodyText.includes(check.expectedBodyContains) + ) { + bodyOk = false; + errorMessages.push(`Expected body to contain '${check.expectedBodyContains}', not found`); + } + + if (statusOk && bodyOk) { return { name: check.name, - status: 'down', + status: 'up', responseTimeMs: responseTime, - error: lastError, + error: '', attempts, }; } + lastError = errorMessages.join('; '); + if (i < check.retries) { + await sleep(check.retryDelayMs); + continue; + } + return { name: check.name, - status: 'up', + status: 'down', responseTimeMs: responseTime, - error: '', + error: lastError, attempts, }; } catch (error) { diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 0129f31..5f8d83c 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -226,4 +226,35 @@ monitors: const config = parseConfig(yaml); expect(config.settings.title).toBe('Atalaya Uptime Monitor'); }); + + it('parses expected_body_contains field', () => { + const yaml = ` +monitors: + - name: "api-health" + type: http + target: "https://api.example.com/health" + expected_body_contains: "healthy" +`; + const config = parseConfig(yaml); + + expect(config.monitors).toHaveLength(1); + if (config.monitors[0].type === 'http') { + expect(config.monitors[0].expectedBodyContains).toBe('healthy'); + } + }); + + it('defaults expected_body_contains to empty string', () => { + const yaml = ` +monitors: + - name: "no-body-check" + type: http + target: "https://example.com" +`; + const config = parseConfig(yaml); + + expect(config.monitors).toHaveLength(1); + if (config.monitors[0].type === 'http') { + expect(config.monitors[0].expectedBodyContains).toBe(''); + } + }); }); diff --git a/src/config/config.ts b/src/config/config.ts index 82e60df..2b72163 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -79,6 +79,7 @@ function applyDefaults(raw: RawYamlConfig): Config { type, method: m.method ?? '', expectedStatus: m.expected_status ?? 0, + expectedBodyContains: m.expected_body_contains ?? '', headers: m.headers ?? {}, }; } diff --git a/src/config/types.ts b/src/config/types.ts index 1e4f660..c2574c6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -33,6 +33,7 @@ export interface HttpMonitor extends MonitorBase { type: 'http'; method: string; expectedStatus: number; + expectedBodyContains: string; headers: Record; } @@ -76,6 +77,7 @@ export type RawYamlConfig = { target?: string; method?: string; expected_status?: number; + expected_body_contains?: string; headers?: Record; record_type?: string; expected_values?: string[]; diff --git a/src/processor/processor.test.ts b/src/processor/processor.test.ts index 3f9f016..6593648 100644 --- a/src/processor/processor.test.ts +++ b/src/processor/processor.test.ts @@ -39,6 +39,7 @@ describe('processResults', () => { method: 'GET', expectedStatus: 200, headers: {}, + expectedBodyContains: '', timeoutMs: 5000, retries: 3, retryDelayMs: 1000, @@ -102,6 +103,7 @@ describe('processResults', () => { method: 'GET', expectedStatus: 200, headers: {}, + expectedBodyContains: '', timeoutMs: 5000, retries: 3, retryDelayMs: 1000, @@ -125,7 +127,7 @@ describe('processResults', () => { { monitor_name: 'test', current_status: 'down', - consecutive_failures: 3, + consecutive_failures: 0, last_status_change: 0, last_checked: 0, }, @@ -145,7 +147,16 @@ describe('processResults', () => { defaultTimeoutMs: 5000, defaultFailureThreshold: 3, }, - alerts: [], + alerts: [ + { + type: 'webhook' as const, + name: 'alert', + url: 'https://example.com', + method: 'POST', + headers: {}, + bodyTemplate: '', + }, + ], monitors: [ { name: 'test', @@ -154,10 +165,11 @@ describe('processResults', () => { method: 'GET', expectedStatus: 200, headers: {}, + expectedBodyContains: '', timeoutMs: 5000, retries: 3, retryDelayMs: 1000, - failureThreshold: 3, + failureThreshold: 2, alerts: ['alert'], }, ], @@ -177,7 +189,7 @@ describe('processResults', () => { { monitor_name: 'test', current_status: 'up', - consecutive_failures: 1, + consecutive_failures: 0, last_status_change: 0, last_checked: 0, }, @@ -199,17 +211,18 @@ describe('processResults', () => { alerts: [], monitors: [ { - name: 'known', + name: 'test', type: 'http', target: 'https://example.com', method: 'GET', expectedStatus: 200, headers: {}, + expectedBodyContains: '', timeoutMs: 5000, retries: 3, retryDelayMs: 1000, failureThreshold: 2, - alerts: [], + alerts: ['alert'], }, ], }; @@ -260,24 +273,25 @@ describe('processResults', () => { alerts: [], monitors: [ { - name: 'new-monitor', + name: 'test', type: 'http', target: 'https://example.com', method: 'GET', expectedStatus: 200, headers: {}, + expectedBodyContains: '', timeoutMs: 5000, retries: 3, retryDelayMs: 1000, - failureThreshold: 2, - alerts: [], + failureThreshold: 3, + alerts: ['alert'], }, ], }; const results: CheckResult[] = [ { - name: 'new-monitor', + name: 'test', status: 'up', responseTimeMs: 100, error: '', diff --git a/src/types.ts b/src/types.ts index 1113737..9b1eab2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ export interface HttpCheckRequest extends CheckRequestBase { method?: string; expectedStatus?: number; headers?: Record; + expectedBodyContains?: string; } export interface TcpCheckRequest extends CheckRequestBase { diff --git a/wrangler.example.toml b/wrangler.example.toml index a862306..ffb7e9d 100644 --- a/wrangler.example.toml +++ b/wrangler.example.toml @@ -111,6 +111,17 @@ monitors: expected_status: 200 timeout_ms: 10000 alerts: ["default"] + + # Example with expected_body_contains + - name: "json-api-health" + type: http + target: "https://api.example.com/health" + method: GET + expected_status: 200 + expected_body_contains: '"status":"healthy"' # Check JSON response contains healthy status + headers: + Content-Type: "application/json" + alerts: ["default"] """ # optional