mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 20:02:21 +00:00
241 lines
11 KiB
TypeScript
241 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);
|
|
});
|
|
});
|