Files
uptime-monitoring/test/monitors.spec.ts
T

265 lines
11 KiB
TypeScript

import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { hashPassword } from '../src/worker/lib/password';
import { resolveFavicon } from '../src/worker/routes/monitors';
const ADMIN_PASSWORD = 'correct-horse-battery-staple';
const VALID_MONITOR = {
name: 'Example',
url: 'https://example.com/health',
method: 'GET',
expectedStatus: 200,
intervalSeconds: 300,
timeoutMs: 10_000,
};
async function seedAdmin() {
await env.DB.batch([
env.DB.prepare('DELETE FROM checks'),
env.DB.prepare('DELETE FROM incidents'),
env.DB.prepare('DELETE FROM monitor_daily_stats'),
env.DB.prepare('DELETE FROM notification_settings'),
env.DB.prepare('DELETE FROM monitors'),
env.DB.prepare('DELETE FROM login_attempts'),
env.DB.prepare('DELETE FROM sessions'),
env.DB.prepare('DELETE FROM admin_credentials'),
]);
const now = Date.now();
await env.DB.prepare('INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)')
.bind(await hashPassword(ADMIN_PASSWORD), now, now)
.run();
}
async function authenticatedCookie() {
const response = await SELF.fetch('https://example.com/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'CF-Connecting-IP': '198.51.100.20',
Origin: 'https://example.com',
},
body: JSON.stringify({ password: ADMIN_PASSWORD }),
});
return response.headers.get('Set-Cookie')?.split(';', 1)[0] ?? '';
}
function apiFetch(path: string, method = 'GET', cookie = '', body?: unknown) {
return SELF.fetch(`https://example.com${path}`, {
method,
headers: {
...(cookie ? { Cookie: cookie } : {}),
...(method !== 'GET' ? { Origin: 'https://example.com' } : {}),
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
}
async function createMonitor(cookie: string, overrides: Record<string, unknown> = {}) {
return apiFetch('/api/monitors', 'POST', cookie, { ...VALID_MONITOR, ...overrides });
}
describe('monitor API', () => {
beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
});
beforeEach(seedAdmin);
afterEach(() => vi.unstubAllGlobals());
it('protects every monitor endpoint, including the collection path without a trailing slash', async () => {
const requests = [
apiFetch('/api/monitors'),
apiFetch('/api/monitors/1/favicon'),
apiFetch('/api/monitors', 'POST', '', VALID_MONITOR),
apiFetch('/api/monitors/1', 'PATCH', '', { name: 'Changed' }),
apiFetch('/api/monitors/1', 'DELETE'),
apiFetch('/api/monitors/1/check', 'POST'),
];
const responses = await Promise.all(requests);
for (const response of responses) {
expect(response.status).toBe(401);
expect(await response.json()).toEqual({ message: 'Authentication required' });
}
});
it('proxies and caches a monitor favicon response', async () => {
const cookie = await authenticatedCookie();
const created = await (
await createMonitor(cookie, { url: 'https://favicon-route.example.test/health' })
).json<{ monitor: { id: number } }>();
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(input.toString());
expect(url.href).toBe('https://favicon-route.example.test/favicon.ico');
return new Response(new Uint8Array([0, 0, 1, 0]), {
headers: { 'Content-Type': 'image/x-icon' },
});
}),
);
const response = await apiFetch(`/api/monitors/${created.monitor.id}/favicon`, 'GET', cookie);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('image/x-icon');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=86400');
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([0, 0, 1, 0]));
});
it('discovers a favicon declared in the website head', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(input.toString());
if (url.pathname === '/favicon.ico') return new Response(null, { status: 404 });
if (url.pathname === '/') {
return new Response('<html><head><link rel="apple-touch-icon" href="/assets/icon.png"></head></html>', {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
return new Response(new Uint8Array([137, 80, 78, 71]), {
headers: { 'Content-Type': 'image/png' },
});
});
vi.stubGlobal('fetch', fetchMock);
const favicon = await resolveFavicon('https://favicon-head.example.test/status');
expect(favicon?.contentType).toBe('image/png');
expect(new Uint8Array(favicon?.body ?? new ArrayBuffer(0))).toEqual(new Uint8Array([137, 80, 78, 71]));
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('does not fetch favicons from private network hosts', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(resolveFavicon('http://127.0.0.1/admin')).resolves.toBeNull();
await expect(resolveFavicon('http://[::1]/admin')).resolves.toBeNull();
await expect(resolveFavicon('http://10.0.0.1/admin')).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('creates a valid monitor and returns it in the list', async () => {
const cookie = await authenticatedCookie();
const response = await createMonitor(cookie);
const created = await response.json<{ monitor: { id: number; name: string; enabled: boolean; alertsEnabled: boolean } }>();
expect(response.status).toBe(200);
expect(created.monitor).toMatchObject({ name: 'Example', enabled: true, alertsEnabled: true });
const listResponse = await apiFetch('/api/monitors', 'GET', cookie);
const list = await listResponse.json<{ monitors: Array<{ id: number; url: string }> }>();
expect(list.monitors).toHaveLength(1);
expect(list.monitors[0]).toMatchObject({ id: created.monitor.id, url: 'https://example.com/health' });
});
it.each([
[{ url: 'file:///etc/passwd' }, 'Enter a valid http or https URL'],
[{ intervalSeconds: 60 }, 'intervalSeconds must be an integer between 300 and 86400'],
[{ expectedStatus: 99 }, 'expectedStatus must be an integer between 100 and 599'],
] as const)('rejects invalid monitor input %o', async (overrides, message) => {
const response = await createMonitor(await authenticatedCookie(), overrides);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ message });
});
it('patches only the requested fields', async () => {
const cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
const response = await apiFetch(`/api/monitors/${created.monitor.id}`, 'PATCH', cookie, {
name: 'Renamed endpoint',
enabled: false,
});
const body = await response.json<{ monitor: { name: string; url: string; enabled: boolean } }>();
expect(response.status).toBe(200);
expect(body.monitor).toMatchObject({
name: 'Renamed endpoint',
url: 'https://example.com/health',
enabled: false,
});
});
it('returns detail, checks, incidents, and raw stats', async () => {
const cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
const id = created.monitor.id;
const now = Date.now();
await env.DB.batch([
env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 100, ?)').bind(
id,
now - 2000,
),
env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 0, 500, 300, ?)').bind(
id,
now - 1000,
),
env.DB.prepare(
"INSERT INTO incidents (monitor_id, started_at, start_status_code, start_error, created_at, updated_at) VALUES (?, ?, 500, 'Down', ?, ?)",
).bind(id, now - 1000, now - 1000, now - 1000),
]);
const [detail, checksResponse, incidentsResponse, statsResponse] = await Promise.all([
apiFetch(`/api/monitors/${id}`, 'GET', cookie),
apiFetch(`/api/monitors/${id}/checks`, 'GET', cookie),
apiFetch(`/api/monitors/${id}/incidents`, 'GET', cookie),
apiFetch(`/api/monitors/${id}/stats`, 'GET', cookie),
]);
expect((await detail.json<{ monitor: { id: number } }>()).monitor.id).toBe(id);
expect((await checksResponse.json<{ checks: unknown[] }>()).checks).toHaveLength(2);
expect((await incidentsResponse.json<{ incidents: unknown[] }>()).incidents).toHaveLength(1);
const stats = await statsResponse.json<{
windows: { '24h': { uptimePct: number; totalChecks: number; upChecks: number; avgLatencyMs: number; incidentCount: number } };
}>();
expect(stats.windows['24h']).toEqual({ uptimePct: 50, totalChecks: 2, upChecks: 1, avgLatencyMs: 200, incidentCount: 1 });
});
it('combines daily rollups with the current partial day for long-range stats', async () => {
const cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
const id = created.monitor.id;
const now = new Date();
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
await env.DB.batch([
env.DB.prepare(
'INSERT INTO monitor_daily_stats (monitor_id, day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms) VALUES (?, ?, 8, 6, 100, 50, 150)',
).bind(id, today - 24 * 60 * 60 * 1000),
env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)').bind(
id,
today + 1000,
),
env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)').bind(
id,
today + 2000,
),
]);
const response = await apiFetch(`/api/monitors/${id}/stats`, 'GET', cookie);
const stats = await response.json<{
windows: { '30d': { uptimePct: number; totalChecks: number; upChecks: number; avgLatencyMs: number } };
}>();
expect(stats.windows['30d']).toMatchObject({
uptimePct: 80,
totalChecks: 10,
upChecks: 8,
avgLatencyMs: 120,
});
});
it('deletes the monitor and its check records explicitly', async () => {
const cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
await env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 12, ?)')
.bind(created.monitor.id, Date.now())
.run();
const response = await apiFetch(`/api/monitors/${created.monitor.id}`, 'DELETE', cookie);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
const monitorCount = await env.DB.prepare('SELECT COUNT(*) AS count FROM monitors').first<{ count: number }>();
const checkCount = await env.DB.prepare('SELECT COUNT(*) AS count FROM checks').first<{ count: number }>();
expect(monitorCount?.count).toBe(0);
expect(checkCount?.count).toBe(0);
});
});