mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
feat: add incident and post history for services
This commit is contained in:
+30
-13
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env, exports as worker } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { deterministicIncidentMessage } from '../src/worker/ai/fallback-message';
|
||||
import { generateIncidentMessage } from '../src/worker/ai/incident-message';
|
||||
@@ -36,6 +37,8 @@ const failedResult = {
|
||||
async function resetDatabase() {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare('DELETE FROM checks'),
|
||||
env.DB.prepare('DELETE FROM incident_updates'),
|
||||
env.DB.prepare('DELETE FROM incident_monitors'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM monitor_daily_stats'),
|
||||
env.DB.prepare('DELETE FROM ai_settings'),
|
||||
@@ -59,10 +62,18 @@ async function seedMonitorAndIncident(aiMessage: string | null = null) {
|
||||
.bind(monitor.name, monitor.url, monitor.lastError, now, now, now)
|
||||
.run();
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO incidents (monitor_id, started_at, resolved_at, start_status_code, start_error, ai_message, duration_ms, created_at, updated_at) VALUES (1, ?, NULL, 503, ?, ?, NULL, ?, ?)',
|
||||
"INSERT INTO incidents (id, status, impact, source, started_at, resolved_at, start_status_code, start_error, duration_ms, created_at, updated_at) VALUES (1, 'investigating', 'major', 'auto', ?, NULL, 503, ?, NULL, ?, ?)",
|
||||
)
|
||||
.bind(now, monitor.lastError, aiMessage, now, now)
|
||||
.bind(now, monitor.lastError, now, now)
|
||||
.run();
|
||||
await env.DB.prepare('INSERT INTO incident_monitors (incident_id, monitor_id) VALUES (1, 1)').run();
|
||||
if (aiMessage) {
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO incident_updates (incident_id, status, body, source, created_at) VALUES (1, 'investigating', ?, 'ai', ?)",
|
||||
)
|
||||
.bind(aiMessage, now)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAiSettings(enabled = true) {
|
||||
@@ -79,7 +90,7 @@ async function authenticatedCookie() {
|
||||
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();
|
||||
const response = await SELF.fetch('https://example.com/api/auth/login', {
|
||||
const response = await worker.default.fetch('https://example.com/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: 'https://example.com' },
|
||||
body: JSON.stringify({ password: ADMIN_PASSWORD }),
|
||||
@@ -88,7 +99,7 @@ async function authenticatedCookie() {
|
||||
}
|
||||
|
||||
async function settingsRequest(path: string, cookie: string, init?: RequestInit) {
|
||||
return SELF.fetch(`https://example.com/api/settings${path}`, {
|
||||
return worker.default.fetch(`https://example.com/api/settings${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -185,7 +196,9 @@ describe('AI incident messages', () => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(generateIncidentMessage(env, { monitor, result: failedResult })).resolves.toBe(expected);
|
||||
const incident = await env.DB.prepare('SELECT ai_message AS aiMessage FROM incidents').first<{ aiMessage: string | null }>();
|
||||
const incident = await env.DB.prepare("SELECT body AS aiMessage FROM incident_updates WHERE source = 'ai'").first<{
|
||||
aiMessage: string | null;
|
||||
}>();
|
||||
expect(incident?.aiMessage).toBe(expected);
|
||||
|
||||
const promptText = String(JSON.parse(String(fetchMock.mock.calls[0][1]?.body)).messages[1].content);
|
||||
@@ -214,8 +227,10 @@ describe('AI incident messages', () => {
|
||||
vi.stubGlobal('fetch', vi.fn(implementation));
|
||||
|
||||
await expect(generateIncidentMessage(env, { monitor, result: failedResult })).resolves.toBeNull();
|
||||
const incident = await env.DB.prepare('SELECT ai_message AS aiMessage FROM incidents').first<{ aiMessage: string | null }>();
|
||||
expect(incident?.aiMessage).toBeNull();
|
||||
const incident = await env.DB.prepare("SELECT body AS aiMessage FROM incident_updates WHERE source = 'ai'").first<{
|
||||
aiMessage: string | null;
|
||||
}>();
|
||||
expect(incident).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects generated content containing a URL', async () => {
|
||||
@@ -227,22 +242,24 @@ describe('AI incident messages', () => {
|
||||
);
|
||||
|
||||
await expect(generateIncidentMessage(env, { monitor, result: failedResult })).resolves.toBeNull();
|
||||
const incident = await env.DB.prepare('SELECT ai_message AS aiMessage FROM incidents').first<{ aiMessage: string | null }>();
|
||||
expect(incident?.aiMessage).toBeNull();
|
||||
const incident = await env.DB.prepare("SELECT body AS aiMessage FROM incident_updates WHERE source = 'ai'").first<{
|
||||
aiMessage: string | null;
|
||||
}>();
|
||||
expect(incident).toBeNull();
|
||||
});
|
||||
|
||||
it('returns stored AI copy and deterministic fallback copy on public status', async () => {
|
||||
await seedMonitorAndIncident('Customers may see delayed API responses.');
|
||||
let body = await (
|
||||
await SELF.fetch('https://example.com/api/status')
|
||||
await worker.default.fetch('https://example.com/api/status')
|
||||
).json<{
|
||||
services: Array<{ message: string | null }>;
|
||||
}>();
|
||||
expect(body.services[0].message).toBe('Customers may see delayed API responses.');
|
||||
|
||||
await env.DB.prepare('UPDATE incidents SET ai_message = NULL').run();
|
||||
await env.DB.prepare('DELETE FROM incident_updates').run();
|
||||
body = await (
|
||||
await SELF.fetch('https://example.com/api/status')
|
||||
await worker.default.fetch('https://example.com/api/status')
|
||||
).json<{
|
||||
services: Array<{ message: string | null }>;
|
||||
}>();
|
||||
|
||||
+7
-6
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env, exports as worker } from 'cloudflare:workers';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { hashPassword } from '../src/worker/lib/password';
|
||||
|
||||
@@ -18,7 +19,7 @@ async function seedAdmin() {
|
||||
}
|
||||
|
||||
function login(password = ADMIN_PASSWORD, ipAddress = '198.51.100.10') {
|
||||
return SELF.fetch('https://example.com/api/auth/login', {
|
||||
return worker.default.fetch('https://example.com/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -65,12 +66,12 @@ describe('authentication', () => {
|
||||
});
|
||||
|
||||
it('returns authentication state from the session endpoint', async () => {
|
||||
const anonymousResponse = await SELF.fetch('https://example.com/api/auth/me');
|
||||
const anonymousResponse = await worker.default.fetch('https://example.com/api/auth/me');
|
||||
expect(anonymousResponse.status).toBe(200);
|
||||
expect(await anonymousResponse.json()).toEqual({ authenticated: false });
|
||||
|
||||
const loginResponse = await login();
|
||||
const authenticatedResponse = await SELF.fetch('https://example.com/api/auth/me', {
|
||||
const authenticatedResponse = await worker.default.fetch('https://example.com/api/auth/me', {
|
||||
headers: { Cookie: cookieFrom(loginResponse) },
|
||||
});
|
||||
|
||||
@@ -81,7 +82,7 @@ describe('authentication', () => {
|
||||
it('revokes the persisted session on logout', async () => {
|
||||
const loginResponse = await login();
|
||||
const cookie = cookieFrom(loginResponse);
|
||||
const logoutResponse = await SELF.fetch('https://example.com/api/auth/logout', {
|
||||
const logoutResponse = await worker.default.fetch('https://example.com/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
@@ -94,7 +95,7 @@ describe('authentication', () => {
|
||||
const sessionCount = await env.DB.prepare('SELECT COUNT(*) AS count FROM sessions').first<{ count: number }>();
|
||||
expect(sessionCount?.count).toBe(0);
|
||||
|
||||
const sessionResponse = await SELF.fetch('https://example.com/api/auth/me', {
|
||||
const sessionResponse = await worker.default.fetch('https://example.com/api/auth/me', {
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
expect(await sessionResponse.json()).toEqual({ authenticated: false });
|
||||
|
||||
+31
-8
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runDueChecks } from '../src/worker/checks/run-due-checks';
|
||||
|
||||
@@ -7,6 +8,8 @@ async function clearMonitoringTables() {
|
||||
env.DB.prepare('DELETE FROM maintenance_window_monitors'),
|
||||
env.DB.prepare('DELETE FROM maintenance_windows'),
|
||||
env.DB.prepare('DELETE FROM checks'),
|
||||
env.DB.prepare('DELETE FROM incident_updates'),
|
||||
env.DB.prepare('DELETE FROM incident_monitors'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM monitor_daily_stats'),
|
||||
env.DB.prepare('DELETE FROM notification_settings'),
|
||||
@@ -103,7 +106,7 @@ describe('scheduled monitor checks', () => {
|
||||
last_error: 'Expected HTTP 200, received 500',
|
||||
});
|
||||
const incident = await env.DB.prepare(
|
||||
'SELECT monitor_id, resolved_at, start_status_code, start_error FROM incidents WHERE monitor_id = ?',
|
||||
'SELECT im.monitor_id, i.resolved_at, i.start_status_code, i.start_error FROM incidents i JOIN incident_monitors im ON im.incident_id = i.id WHERE im.monitor_id = ?',
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ monitor_id: number; resolved_at: number | null; start_status_code: number; start_error: string }>();
|
||||
@@ -122,26 +125,46 @@ describe('scheduled monitor checks', () => {
|
||||
vi.fn(async () => new Response(null, { status: 503 })),
|
||||
);
|
||||
await runDueChecks(env);
|
||||
const count = await env.DB.prepare('SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?').bind(id).first<{ count: number }>();
|
||||
const count = await env.DB.prepare('SELECT COUNT(*) AS count FROM incident_monitors WHERE monitor_id = ?')
|
||||
.bind(id)
|
||||
.first<{ count: number }>();
|
||||
expect(count?.count).toBe(0);
|
||||
});
|
||||
|
||||
it('links each auto incident to the correct monitor in one scheduled batch', async () => {
|
||||
const firstId = await insertMonitor({ name: 'First', url: 'https://first.example.com' });
|
||||
const secondId = await insertMonitor({ name: 'Second', url: 'https://second.example.com' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status: 503 })),
|
||||
);
|
||||
|
||||
await runDueChecks(env);
|
||||
const assignments = await env.DB.prepare('SELECT incident_id, monitor_id FROM incident_monitors ORDER BY incident_id').all<{
|
||||
incident_id: number;
|
||||
monitor_id: number;
|
||||
}>();
|
||||
expect(assignments.results.map((row) => row.monitor_id)).toEqual([firstId, secondId]);
|
||||
expect(new Set(assignments.results.map((row) => row.incident_id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves the open incident on recovery', async () => {
|
||||
const id = await insertMonitor({ last_ok: 0 });
|
||||
const startedAt = Date.now() - 60_000;
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO incidents (monitor_id, started_at, start_status_code, start_error, created_at, updated_at) VALUES (?, ?, 500, 'Down', ?, ?)",
|
||||
const inserted = await env.DB.prepare(
|
||||
"INSERT INTO incidents (status, impact, source, started_at, start_status_code, start_error, created_at, updated_at) VALUES ('investigating', 'major', 'auto', ?, 500, 'Down', ?, ?)",
|
||||
)
|
||||
.bind(id, startedAt, startedAt, startedAt)
|
||||
.bind(startedAt, startedAt, startedAt)
|
||||
.run();
|
||||
await env.DB.prepare('INSERT INTO incident_monitors (incident_id, monitor_id) VALUES (?, ?)').bind(inserted.meta.last_row_id, id).run();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status: 200 })),
|
||||
);
|
||||
|
||||
await runDueChecks(env);
|
||||
const incident = await env.DB.prepare('SELECT resolved_at, duration_ms FROM incidents WHERE monitor_id = ?')
|
||||
.bind(id)
|
||||
const incident = await env.DB.prepare('SELECT resolved_at, duration_ms FROM incidents WHERE id = ?')
|
||||
.bind(inserted.meta.last_row_id)
|
||||
.first<{ resolved_at: number | null; duration_ms: number | null }>();
|
||||
expect(incident?.resolved_at).toEqual(expect.any(Number));
|
||||
expect(incident?.duration_ms).toBeGreaterThanOrEqual(60_000);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env, exports as worker } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { hashPassword } from '../src/worker/lib/password';
|
||||
|
||||
const PASSWORD = 'correct-horse-battery-staple';
|
||||
async function reset() {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare('DELETE FROM incident_updates'),
|
||||
env.DB.prepare('DELETE FROM incident_monitors'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM monitors'),
|
||||
env.DB.prepare('DELETE FROM ai_settings'),
|
||||
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(PASSWORD), now, now)
|
||||
.run();
|
||||
}
|
||||
async function cookie() {
|
||||
const response = await worker.default.fetch('https://example.com/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: 'https://example.com' },
|
||||
body: JSON.stringify({ password: PASSWORD }),
|
||||
});
|
||||
return response.headers.get('Set-Cookie')?.split(';', 1)[0] ?? '';
|
||||
}
|
||||
function post(path: string, auth: string, body: unknown) {
|
||||
return worker.default.fetch(`https://example.com${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: 'https://example.com', ...(auth ? { Cookie: auth } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
async function enableAi() {
|
||||
const now = Date.now();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO ai_settings (id, enabled, base_url, api_key, model, created_at, updated_at) VALUES (1, 1, 'https://api.example.com/v1', 'secret', 'small', ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
}
|
||||
|
||||
describe('AI incident drafts', () => {
|
||||
beforeAll(async () => applyD1Migrations(env.DB, (env as Env & { TEST_MIGRATIONS: D1Migration[] }).TEST_MIGRATIONS));
|
||||
beforeEach(reset);
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
it('requires authentication and returns 409 without configured AI', async () => {
|
||||
expect((await post('/api/incidents/draft', '', { note: 'down', status: 'investigating' })).status).toBe(401);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect((await post('/api/incidents/draft', await cookie(), { note: 'down', status: 'investigating' })).status).toBe(409);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it('returns a clean draft without writing database rows', async () => {
|
||||
await enableAi();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
Response.json({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content:
|
||||
'TITLE: Delayed customer requests\nBODY: Some requests are taking longer than expected. We are working to restore normal performance.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const response = await post('/api/incidents/draft', await cookie(), {
|
||||
note: 'redis full memory, scale RAM',
|
||||
status: 'identified',
|
||||
monitorIds: [],
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
title: 'Delayed customer requests',
|
||||
body: 'Some requests are taking longer than expected. We are working to restore normal performance.',
|
||||
});
|
||||
expect((await env.DB.prepare('SELECT count(*) AS count FROM incidents').first<{ count: number }>())?.count).toBe(0);
|
||||
expect((await env.DB.prepare('SELECT count(*) AS count FROM incident_updates').first<{ count: number }>())?.count).toBe(0);
|
||||
});
|
||||
it('rejects unsafe model output and varies guidance by lifecycle status', async () => {
|
||||
await enableAi();
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_input, init) => {
|
||||
calls.push(String(init?.body));
|
||||
return Response.json({
|
||||
choices: [{ message: { content: 'TITLE: Service issue\nBODY: See https://internal.example.com HTTP 500.' } }],
|
||||
});
|
||||
}),
|
||||
);
|
||||
const auth = await cookie();
|
||||
expect((await post('/api/incidents/draft', auth, { note: 'same', status: 'identified', monitorIds: [] })).status).toBe(422);
|
||||
expect((await post('/api/incidents/draft', auth, { note: 'same', status: 'resolved', monitorIds: [] })).status).toBe(422);
|
||||
expect(calls[0]).toContain('cause has been identified');
|
||||
expect(calls[1]).toContain('operating normally again');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env, exports as worker } from 'cloudflare:workers';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { hashPassword } from '../src/worker/lib/password';
|
||||
|
||||
const PASSWORD = 'correct-horse-battery-staple';
|
||||
async function reset() {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare('DELETE FROM incident_updates'),
|
||||
env.DB.prepare('DELETE FROM incident_monitors'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM checks'),
|
||||
env.DB.prepare('DELETE FROM monitors'),
|
||||
env.DB.prepare('DELETE FROM sessions'),
|
||||
env.DB.prepare('DELETE FROM admin_credentials'),
|
||||
env.DB.prepare('DELETE FROM ai_settings'),
|
||||
]);
|
||||
const now = Date.now();
|
||||
await env.DB.prepare('INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)')
|
||||
.bind(await hashPassword(PASSWORD), now, now)
|
||||
.run();
|
||||
}
|
||||
async function cookie() {
|
||||
const response = await worker.default.fetch('https://example.com/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: 'https://example.com' },
|
||||
body: JSON.stringify({ password: PASSWORD }),
|
||||
});
|
||||
return response.headers.get('Set-Cookie')?.split(';', 1)[0] ?? '';
|
||||
}
|
||||
function request(path: string, method = 'GET', auth = '', body?: unknown) {
|
||||
return worker.default.fetch(`https://example.com${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(auth ? { Cookie: auth } : {}),
|
||||
...(method !== 'GET' ? { Origin: 'https://example.com' } : {}),
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
async function monitor(name: string) {
|
||||
const now = Date.now();
|
||||
const result = await env.DB.prepare(
|
||||
"INSERT INTO monitors (name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, last_ok, created_at, updated_at) VALUES (?, 'https://example.com', 'GET', 200, 300, 10000, 1, 1, 1, ?, ?)",
|
||||
)
|
||||
.bind(name, now, now)
|
||||
.run();
|
||||
return Number(result.meta.last_row_id);
|
||||
}
|
||||
|
||||
describe('incident lifecycle API', () => {
|
||||
beforeAll(async () => applyD1Migrations(env.DB, (env as Env & { TEST_MIGRATIONS: D1Migration[] }).TEST_MIGRATIONS));
|
||||
beforeEach(reset);
|
||||
it('protects admin incident routes', async () => {
|
||||
for (const [path, method] of [
|
||||
['/api/incidents', 'GET'],
|
||||
['/api/incidents', 'POST'],
|
||||
['/api/incidents/draft', 'POST'],
|
||||
['/api/incidents/1', 'GET'],
|
||||
['/api/incidents/1/updates', 'POST'],
|
||||
] as const)
|
||||
expect((await request(path, method)).status).toBe(401);
|
||||
});
|
||||
it('creates a multi-service manual incident and resolves it with an update', async () => {
|
||||
const ids = await Promise.all(['API', 'Web', 'Jobs'].map(monitor));
|
||||
const auth = await cookie();
|
||||
const created = await request('/api/incidents', 'POST', auth, {
|
||||
title: 'Delayed requests',
|
||||
impact: 'critical',
|
||||
status: 'investigating',
|
||||
body: 'Some requests are taking longer than expected. We are investigating.',
|
||||
note: 'redis memory',
|
||||
monitorIds: ids,
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const body = await created.json<{ incident: { id: number; monitorIds: number[]; updates: unknown[] } }>();
|
||||
expect(body.incident.monitorIds).toHaveLength(3);
|
||||
expect(body.incident.updates).toHaveLength(1);
|
||||
const resolved = await request(`/api/incidents/${body.incident.id}/updates`, 'POST', auth, {
|
||||
status: 'resolved',
|
||||
body: 'Service is operating normally again.',
|
||||
note: 'scaled',
|
||||
});
|
||||
expect(resolved.status).toBe(200);
|
||||
const row = await env.DB.prepare('SELECT status, resolved_at, duration_ms FROM incidents WHERE id = ?').bind(body.incident.id).first();
|
||||
expect(row).toMatchObject({ status: 'resolved' });
|
||||
expect(row?.resolved_at).toEqual(expect.any(Number));
|
||||
});
|
||||
it('publishes a service-less critical incident without leaking internal notes', async () => {
|
||||
const auth = await cookie();
|
||||
const created = await request('/api/incidents', 'POST', auth, {
|
||||
title: 'Sign-in disruption',
|
||||
impact: 'critical',
|
||||
status: 'investigating',
|
||||
body: 'Some customers cannot sign in. We are investigating.',
|
||||
note: 'internal secret redis',
|
||||
monitorIds: [],
|
||||
});
|
||||
const incident = (await created.json<{ incident: { id: number } }>()).incident;
|
||||
const status = await (await request('/api/status')).json<{ overall: string; activeIncidents: Array<{ services: unknown[] }> }>();
|
||||
expect(status.overall).toBe('down');
|
||||
expect(status.activeIncidents[0].services).toEqual([]);
|
||||
const detailText = await (await request(`/api/status/incidents/${incident.id}`)).text();
|
||||
expect(detailText).not.toContain('internal secret');
|
||||
expect(detailText).not.toContain('note');
|
||||
});
|
||||
it('keeps an incident after its assigned monitor is deleted', async () => {
|
||||
const id = await monitor('API');
|
||||
const auth = await cookie();
|
||||
const created = await request('/api/incidents', 'POST', auth, {
|
||||
title: 'API issue',
|
||||
impact: 'major',
|
||||
status: 'investigating',
|
||||
body: 'Some requests are failing.',
|
||||
monitorIds: [id],
|
||||
});
|
||||
const incidentId = (await created.json<{ incident: { id: number } }>()).incident.id;
|
||||
expect((await request(`/api/monitors/${id}`, 'DELETE', auth)).status).toBe(200);
|
||||
expect(await env.DB.prepare('SELECT id FROM incidents WHERE id = ?').bind(incidentId).first()).toBeTruthy();
|
||||
expect(await env.DB.prepare('SELECT * FROM incident_monitors WHERE incident_id = ?').bind(incidentId).first()).toBeNull();
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { exports as worker } from 'cloudflare:workers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('uptime monitoring Worker', () => {
|
||||
it('returns a successful D1 health response', async () => {
|
||||
const response = await SELF.fetch('https://example.com/api/health');
|
||||
const response = await worker.default.fetch('https://example.com/api/health');
|
||||
const body = await response.json<{
|
||||
ok: boolean;
|
||||
db: { ok: number } | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runDueChecks } from '../src/worker/checks/run-due-checks';
|
||||
import type { MaintenanceWindowRow } from '../src/worker/maintenance/windows';
|
||||
@@ -23,6 +24,8 @@ async function resetDatabase() {
|
||||
env.DB.prepare('DELETE FROM maintenance_window_monitors'),
|
||||
env.DB.prepare('DELETE FROM maintenance_windows'),
|
||||
env.DB.prepare('DELETE FROM checks'),
|
||||
env.DB.prepare('DELETE FROM incident_updates'),
|
||||
env.DB.prepare('DELETE FROM incident_monitors'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM monitor_daily_stats'),
|
||||
env.DB.prepare('DELETE FROM notification_settings'),
|
||||
@@ -82,7 +85,7 @@ describe('maintenance windows', () => {
|
||||
await runDueChecks(env);
|
||||
const check = await env.DB.prepare('SELECT maintenance, ok FROM checks WHERE monitor_id = ?').bind(id).first();
|
||||
const monitor = await env.DB.prepare('SELECT last_ok, last_status_code FROM monitors WHERE id = ?').bind(id).first();
|
||||
const incident = await env.DB.prepare('SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?')
|
||||
const incident = await env.DB.prepare('SELECT COUNT(*) AS count FROM incident_monitors WHERE monitor_id = ?')
|
||||
.bind(id)
|
||||
.first<{ count: number }>();
|
||||
expect(check).toMatchObject({ maintenance: 1, ok: 0 });
|
||||
@@ -101,7 +104,7 @@ describe('maintenance windows', () => {
|
||||
|
||||
await runDueChecks(env);
|
||||
const check = await env.DB.prepare('SELECT maintenance FROM checks WHERE monitor_id = ?').bind(id).first();
|
||||
const incident = await env.DB.prepare('SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?')
|
||||
const incident = await env.DB.prepare('SELECT COUNT(*) AS count FROM incident_monitors WHERE monitor_id = ?')
|
||||
.bind(id)
|
||||
.first<{ count: number }>();
|
||||
expect(check).toMatchObject({ maintenance: 0 });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env, exports as worker } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { hashPassword } from '../src/worker/lib/password';
|
||||
import { resolveFavicon } from '../src/worker/routes/monitors';
|
||||
@@ -16,6 +17,8 @@ const VALID_MONITOR = {
|
||||
async function seedAdmin() {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare('DELETE FROM checks'),
|
||||
env.DB.prepare('DELETE FROM incident_updates'),
|
||||
env.DB.prepare('DELETE FROM incident_monitors'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM monitor_daily_stats'),
|
||||
env.DB.prepare('DELETE FROM notification_settings'),
|
||||
@@ -31,7 +34,7 @@ async function seedAdmin() {
|
||||
}
|
||||
|
||||
async function authenticatedCookie() {
|
||||
const response = await SELF.fetch('https://example.com/api/auth/login', {
|
||||
const response = await worker.default.fetch('https://example.com/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -44,7 +47,7 @@ async function authenticatedCookie() {
|
||||
}
|
||||
|
||||
function apiFetch(path: string, method = 'GET', cookie = '', body?: unknown) {
|
||||
return SELF.fetch(`https://example.com${path}`, {
|
||||
return worker.default.fetch(`https://example.com${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
@@ -194,8 +197,9 @@ describe('monitor API', () => {
|
||||
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),
|
||||
"INSERT INTO incidents (id, status, impact, source, started_at, start_status_code, start_error, created_at, updated_at) VALUES (100, 'investigating', 'major', 'auto', ?, 500, 'Down', ?, ?)",
|
||||
).bind(now - 1000, now - 1000, now - 1000),
|
||||
env.DB.prepare('INSERT INTO incident_monitors (incident_id, monitor_id) VALUES (100, ?)').bind(id),
|
||||
]);
|
||||
|
||||
const [detail, checksResponse, incidentsResponse, statsResponse] = await Promise.all([
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { runDailyRollup } from '../src/worker/scheduled/rollup';
|
||||
|
||||
|
||||
+4
-3
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env, exports as worker } from 'cloudflare:workers';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -69,7 +70,7 @@ async function insertMonitor(input: {
|
||||
}
|
||||
|
||||
function statusFetch(path = '/api/status') {
|
||||
return SELF.fetch(`https://example.com${path}`);
|
||||
return worker.default.fetch(`https://example.com${path}`);
|
||||
}
|
||||
|
||||
describe('public status API', () => {
|
||||
@@ -182,7 +183,7 @@ describe('public status API', () => {
|
||||
});
|
||||
|
||||
it('keeps the administrative monitor collection protected', async () => {
|
||||
const response = await SELF.fetch('https://example.com/api/monitors');
|
||||
const response = await worker.default.fetch('https://example.com/api/monitors');
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ message: 'Authentication required' });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { sendIncidentAlert } from '../src/worker/notifications/webhook';
|
||||
import type { Monitor } from '../src/worker/checks/run-check';
|
||||
|
||||
Reference in New Issue
Block a user