import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { loadConfig } from '../server/config.mjs'; import { runProbe, checkService, nextState } from '../server/probe.mjs'; import { openStore } from '../server/store.mjs'; import { makeSnapshot, summarize, overallStatus } from '../server/snapshot.mjs'; import { createMonitor } from '../server/monitor.mjs'; import { demoSnapshot } from '../server/demo.mjs'; const config = loadConfig({}); const service = config.services[0]; const healthy = { ok: true, latencyMs: 120, httpCode: 200, reason: null }; const failed = { ok: false, latencyMs: null, httpCode: 503, reason: 'http' }; const fetchHtml = async () => new Response('RideBus', { headers: { 'content-type': 'text/html' } }); test('configured services match the five RideBus domains and remain server-side', () => { assert.equal(config.services.length, 5); assert.deepEqual(config.services.map(item => new URL(item.publicUrl).hostname).sort(), ['admin.ridebus.by', 'api.ridebus.by', 'board.ridebus.by', 'pwa.ridebus.by', 'ridebus.by']); const keyed = loadConfig({ RIDEBUS_API_KEY: 'test-secret' }); assert.equal(keyed.services[0].probes.length, 2); assert.equal(keyed.services[0].probes[1].headers['X-API-Key'], 'test-secret'); assert.equal(loadConfig({ STATUS_API_URL: 'off' }).services[0].probes.length, 0); assert.throws(() => loadConfig({ STATUS_INTERVAL_SECONDS: '0' })); assert.throws(() => loadConfig({ STATUS_API_URL: 'https://user:secret@example.com' })); }); test('HTML probes check content as well as HTTP status', async () => { const probe = { url: 'https://example.com', kind: 'html', headers: {} }; assert.equal((await runProbe(probe, { timeoutMs: 100, fetchImpl: fetchHtml })).ok, true); const invalid = await runProbe(probe, { timeoutMs: 100, fetchImpl: async () => new Response('{}', { headers: { 'content-type': 'application/json' } }) }); assert.equal(invalid.reason, 'content'); const failure = await runProbe(probe, { timeoutMs: 100, fetchImpl: async () => new Response('gateway', { status: 503 }) }); assert.equal(failure.reason, 'http'); assert.equal(failure.httpCode, 503); }); test('API probes reject invalid health JSON and require data in the optional functional probe', async () => { assert.equal((await runProbe(service.probes[0], { timeoutMs: 100, fetchImpl: async () => Response.json({ status: 'healthy' }) })).ok, true); assert.equal((await runProbe(service.probes[0], { timeoutMs: 100, fetchImpl: async () => Response.json({ status: 'unhealthy' }) })).reason, 'content'); const keyed = loadConfig({ RIDEBUS_API_KEY: 'test-secret' }).services[0]; const result = await checkService(keyed, { timeoutMs: 100, fetchImpl: async url => url.endsWith('/health') ? Response.json({ status: 'healthy' }) : Response.json([]) }); assert.equal(result.ok, false); assert.equal(result.reason, 'content'); }); test('timeouts and network errors are sanitized without exposing URLs or secrets', async () => { const probe = { url: 'https://example.com?secret=value', kind: 'http', headers: {} }; const timeout = await runProbe(probe, { timeoutMs: 15, fetchImpl: (_url, { signal }) => new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(new Error('private timeout detail')))) }); assert.equal(timeout.reason, 'timeout'); const network = await runProbe(probe, { timeoutMs: 100, fetchImpl: async () => { throw new Error('secret credentials and URL'); } }); assert.equal(network.reason, 'network'); assert.doesNotMatch(JSON.stringify(network), /secret|credentials/); }); test('custom credentials are removed on cross-origin redirects', async () => { const calls = []; const result = await runProbe({ url: 'https://api.example.com/health', kind: 'json', headers: { 'X-API-Key': 'secret' } }, { timeoutMs: 100, fetchImpl: async (url, options) => { calls.push({ url, headers: options.headers }); return calls.length === 1 ? new Response(null, { status: 302, headers: { location: 'https://other.example.com/health' } }) : Response.json({ status: 'healthy' }); }, }); assert.equal(result.ok, true); assert.equal(calls[0].headers['X-API-Key'], 'secret'); assert.equal(calls[1].headers['X-API-Key'], undefined); }); test('a very large response is rejected rather than kept in memory', async () => { const result = await runProbe(service.probes[0], { timeoutMs: 100, fetchImpl: async () => new Response('x'.repeat(1024 * 1024 + 1)) }); assert.equal(result.reason, 'content'); }); test('outage confirmation and recovery survive across checks without claiming a failed probe is healthy', () => { let state = nextState(null, healthy, config); assert.equal(state.status, 'operational'); state = nextState(state, failed, config); assert.equal(state.status, 'degraded'); state = nextState(state, failed, config); assert.equal(state.status, 'degraded'); state = nextState(state, failed, config); assert.equal(state.status, 'down'); state = nextState(state, healthy, config); assert.equal(state.status, 'down'); state = nextState(state, healthy, config); assert.equal(state.status, 'operational'); assert.equal(nextState(state, { ...healthy, latencyMs: 2000 }, config).status, 'degraded'); }); test('SQLite persists history and creates one incident only after confirmation, then resolves it', () => { const folder = mkdtempSync(join(tmpdir(), 'ridebus-status-store-')); const file = join(folder, 'status.sqlite'); let store = openStore(file); try { for (let index = 1; index <= 3; index++) store.record(service, failed, nextState(store.state(service), failed, config), 1000 * index); assert.equal(store.incidents().length, 1); assert.equal(store.incidents()[0].resolvedAt, null); store.close(); store = openStore(file); assert.equal(store.state(service).status, 'down'); store.record(service, healthy, nextState(store.state(service), healthy, config), 4000); assert.equal(store.incidents()[0].resolvedAt, null); store.record(service, healthy, nextState(store.state(service), healthy, config), 5000); assert.equal(store.incidents()[0].resolvedAt, 5000); assert.equal(store.checks(service, 0).length, 5); } finally { store.close(); rmSync(folder, { recursive: true, force: true }); } }); test('stale results become unknown while historical gaps remain visible', () => { const store = openStore(':memory:'); const now = Date.UTC(2026, 9, 12, 12); try { store.record(service, healthy, nextState(null, healthy, config), now - 181000); const snapshot = makeSnapshot(store, config, 30, now); assert.equal(snapshot.services[0].status, 'unknown'); assert.equal(snapshot.services[0].stale, true); assert.equal(snapshot.overall, 'unknown'); assert.equal(snapshot.services[0].history[0].status, 'unknown'); assert.equal(snapshot.services[0].history[0].uptime, null); assert.equal(snapshot.services[0].history.at(-1).uptime, 100); assert.ok(snapshot.services[0].history.at(-1).coverage < 95); } finally { store.close(); } }); test('sample availability excludes unconfigured observations and does not fill missing hours', () => { assert.equal(summarize([]).uptime, null); const result = summarize([{ ok: 1, latencyMs: 120, reason: null }, { ok: 0, latencyMs: null, reason: 'http' }, { ok: 0, latencyMs: null, reason: 'unconfigured' }]); assert.equal(result.uptime, 50); assert.equal(result.checks, 2); const store = openStore(':memory:'); try { const now = Date.UTC(2026, 9, 12, 12); store.record(service, healthy, nextState(null, healthy, config), now); const item = makeSnapshot(store, config, 30, now).services[0]; assert.equal(item.responseHistory[0].latencyMs, null); assert.equal(item.history[0].checks, 0); } finally { store.close(); } }); test('changing a probe target starts separate history and no sensitive config is published', () => { const keyedConfig = loadConfig({ RIDEBUS_API_KEY: 'not-public', STATUS_API_URL: 'https://api.ridebus.by/private-check?credential=hidden' }); const store = openStore(':memory:'); try { store.record(service, healthy, nextState(null, healthy, config)); const snapshot = makeSnapshot(store, keyedConfig); assert.equal(snapshot.services[0].checks, 0); assert.doesNotMatch(JSON.stringify(snapshot), /not-public|private-check|credential|hidden|X-API-Key/); } finally { store.close(); } }); test('database aggregation preserves sample availability, daily severity, latency and hour gaps', () => { const store = openStore(':memory:'); const now = Date.UTC(2026, 9, 12, 12); try { store.record(service, healthy, { status: 'operational', failures: 0, successes: 1 }, now - 300000); store.record(service, failed, { status: 'degraded', failures: 1, successes: 0 }, now - 240000); const item = makeSnapshot(store, config, 30, now).services[0]; assert.equal(item.uptime, 50); assert.equal(item.avgLatencyMs, 120); assert.equal(item.latencyMs, null); assert.equal(item.history.at(-1).status, 'degraded'); assert.equal(item.history.at(-1).uptime, 50); assert.equal(item.responseHistory.at(-1).checks, 2); assert.equal(item.responseHistory[0].checks, 0); assert.equal(item.recentChecks.length, 2); } finally { store.close(); } }); test('overlapping monitoring cycles coalesce and perform only one check per service', async () => { const store = openStore(':memory:'); let calls = 0, resolve; const result = new Promise(done => { resolve = done; }); const monitor = createMonitor(store, config, { check: async () => { calls++; return result; } }); try { const first = monitor.cycle(), second = monitor.cycle(); assert.equal(calls, 5); resolve(healthy); await Promise.all([first, second]); for (const item of config.services) assert.equal(store.checks(item, 0).length, 1); } finally { await monitor.stop(); store.close(); } }); test('overall operational status requires fresh successful data for every service', () => { assert.equal(overallStatus([{ status: 'operational' }, { status: 'unknown' }]), 'unknown'); assert.equal(overallStatus([{ status: 'operational' }, { status: 'down' }]), 'degraded'); assert.equal(overallStatus([{ status: 'down' }, { status: 'down' }]), 'down'); }); test('manual incidents have an explicit lifecycle and demo data is marked', () => { const store = openStore(':memory:'); try { const id = store.createIncident({ titleRu: 'Проверяем', titleEn: 'Investigating', messageRu: 'Сообщение', messageEn: 'Update' }, 1000); assert.equal(store.incidents()[0].source, 'manual'); assert.equal(store.resolveIncident(id, 2000), true); assert.equal(store.resolveIncident(id, 3000), false); assert.equal(store.incidents()[0].resolvedAt, 2000); const demo = demoSnapshot(30); assert.equal(demo.demo, true); assert.equal(demo.services.length, 5); assert.equal(makeSnapshot(store, config).demo, false); assert.equal(makeSnapshot(store, config).services[0].checks, 0); } finally { store.close(); } });