157 lines
11 KiB
JavaScript
157 lines
11 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { loadConfig } from '../server/config.mjs';
|
|
import { openStore } from '../server/store.mjs';
|
|
import { nextState } from '../server/probe.mjs';
|
|
import { makeSnapshot } from '../server/snapshot.mjs';
|
|
import { createStatusServer } from '../server/index.mjs';
|
|
import { buildFeed, feedOptions, xmlEscape } from '../server/feed.mjs';
|
|
import { serviceImpact } from '../shared/impact.mjs';
|
|
|
|
const config = loadConfig({});
|
|
const api = config.services[0];
|
|
const good = { ok: true, latencyMs: 100, httpCode: 200, reason: null };
|
|
const bad = { ok: false, latencyMs: null, httpCode: 503, reason: 'http' };
|
|
const manual = { serviceId: 'api', titleRu: 'Сбой & <расписания>', titleEn: 'Timetable & <issue>', messageRu: 'Проверяем расписание.', messageEn: 'Checking the timetable.' };
|
|
|
|
test('automatic timeline separates detection, team investigation and genuine recovery without duplicate events', () => {
|
|
const store = openStore(':memory:');
|
|
try {
|
|
for (const time of [1000, 2000, 3000, 4000]) store.record(api, bad, nextState(store.state(api), bad, config), time);
|
|
let incident = store.incidents()[0];
|
|
assert.equal(incident.updates.length, 1);
|
|
assert.deepEqual(incident.updates.map(update => [update.source, update.stage]), [['monitor', 'detected']]);
|
|
store.updateIncident(incident.id, { stage: 'investigating', messageRu: 'Команда проверяет.', messageEn: 'The team is investigating.' }, 5000);
|
|
assert.equal(store.resolveIncident(incident.id, 6000), false);
|
|
for (const time of [6000, 7000, 8000]) store.record(api, good, nextState(store.state(api), good, config), time);
|
|
incident = store.incidents()[0];
|
|
assert.deepEqual(incident.updates.map(update => [update.source, update.stage]), [['monitor', 'detected'], ['manual', 'investigating'], ['monitor', 'resolved']]);
|
|
assert.equal(incident.resolvedAt, 7000);
|
|
assert.throws(() => store.updateIncident(incident.id, { stage: 'identified', messageRu: 'Причина', messageEn: 'Cause' }, 9000), /Closed incidents/);
|
|
store.updateIncident(incident.id, { stage: 'postmortem', messageRu: 'Итоги: восстановили источник.', messageEn: 'Outcome: restored the source.' }, 9000);
|
|
const closed = store.incidents()[0];
|
|
assert.equal(closed.resolvedAt, 7000);
|
|
assert.equal(closed.updates.at(-1).stage, 'postmortem');
|
|
assert.equal(closed.updates.at(-1).source, 'manual');
|
|
} finally { store.close(); }
|
|
});
|
|
|
|
test('manual reports keep their original text and store a final explanation without accepting invalid updates', () => {
|
|
const store = openStore(':memory:');
|
|
try {
|
|
const id = store.createIncident(manual, 1000);
|
|
assert.equal(store.incidents()[0].updates[0].stage, 'reported');
|
|
assert.throws(() => store.updateIncident(id, { stage: 'postmortem', messageRu: 'Итоги', messageEn: 'Outcome' }, 2000), /Resolve/);
|
|
assert.throws(() => store.updateIncident(id, { stage: 'resolved', messageRu: 'Нет', messageEn: 'No' }, 2000), /Stage/);
|
|
assert.throws(() => store.updateIncident(id, { stage: 'identified', messageRu: 'Причина' }, 2000), /message/);
|
|
store.updateIncident(id, { stage: 'monitoring', messageRu: 'Наблюдаем.', messageEn: 'Monitoring.' }, 3000);
|
|
assert.throws(() => store.updateIncident(id, { stage: 'investigating', messageRu: 'Текст', messageEn: 'Text' }, 2000), /timeline/);
|
|
assert.equal(store.resolveIncident(id, 4000, { messageRu: 'Причина: ошибка источника. Исправлено.', messageEn: 'Cause: source error. Fixed.' }), true);
|
|
assert.equal(store.resolveIncident(id, 5000), false);
|
|
const incident = store.incidents()[0];
|
|
assert.equal(incident.message.ru, manual.messageRu);
|
|
assert.match(incident.updates.at(-1).message.en, /source error/);
|
|
assert.equal(incident.updates.length, 3);
|
|
} finally { store.close(); }
|
|
});
|
|
|
|
test('legacy incidents gain only recorded timeline facts and repeated startup is idempotent', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'ridebus-updates-'));
|
|
const path = join(dir, 'legacy.sqlite');
|
|
const db = new DatabaseSync(path);
|
|
db.exec(`CREATE TABLE incidents (id INTEGER PRIMARY KEY, source TEXT NOT NULL, service_id TEXT, signature TEXT, started_at INTEGER NOT NULL, resolved_at INTEGER, title_ru TEXT NOT NULL, title_en TEXT NOT NULL, message_ru TEXT NOT NULL, message_en TEXT NOT NULL);
|
|
INSERT INTO incidents VALUES (1, 'monitor', 'api', 'old', 1000, 2000, 'Сбой', 'Outage', 'Старое сообщение', 'Original message');`);
|
|
db.close();
|
|
try {
|
|
for (let index = 0; index < 2; index++) {
|
|
const store = openStore(path);
|
|
const incident = store.incidents()[0];
|
|
assert.deepEqual(incident.updates.map(update => update.stage), ['detected', 'resolved']);
|
|
assert.equal(incident.updates[0].message.en, 'Original message');
|
|
assert.equal(incident.resolvedAt, 2000);
|
|
store.close();
|
|
}
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('impact explains the failed scope and suggests only a currently verified alternative', () => {
|
|
const board = { ...config.services.find(service => service.id === 'board'), status: 'degraded', availabilityStatus: 'operational', scenario: { status: 'down' } };
|
|
const pwa = { ...config.services.find(service => service.id === 'pwa'), status: 'operational' };
|
|
assert.equal(serviceImpact(board, [pwa]).scope, 'scenario');
|
|
assert.deepEqual(serviceImpact(board, [pwa]).alternatives.map(item => item.id), ['pwa']);
|
|
assert.equal(serviceImpact(board, [{ ...pwa, status: 'unknown' }]).alternatives.length, 0);
|
|
assert.equal(serviceImpact({ ...board, status: 'unknown' }, [pwa]), null);
|
|
assert.equal(serviceImpact({ ...board, status: 'maintenance' }, [pwa]), null);
|
|
assert.equal(serviceImpact({ ...board, status: 'down', availabilityStatus: 'down' }, [pwa]).scope, 'availability');
|
|
});
|
|
|
|
test('RSS selections are strict and deterministic, including empty, duplicate and unknown parameters', () => {
|
|
assert.deepEqual(feedOptions(new URLSearchParams('lang=en&services=pwa,api,pwa&types=maintenance'), config.services), { locale: 'en', serviceIds: ['api', 'pwa'], types: ['maintenance'] });
|
|
for (const query of ['services=', 'services=missing', 'types=', 'types=spam', 'lang=zz', 'lang=', 'lang=ru&lang=en', 'demo=1']) assert.throws(() => feedOptions(new URLSearchParams(query), config.services));
|
|
});
|
|
|
|
test('RSS contains each incident update, escapes XML and keeps event IDs stable across polling', () => {
|
|
const store = openStore(':memory:');
|
|
try {
|
|
const id = store.createIncident(manual, 1000);
|
|
store.updateIncident(id, { stage: 'identified', messageRu: 'Причина <ошибки> & исправление', messageEn: 'Cause <error> & fix' }, 2000);
|
|
const options = feedOptions(new URLSearchParams('lang=en&services=api&types=incidents'), config.services);
|
|
const first = buildFeed(makeSnapshot(store, config, 7, 3000), options, 'https://status.example');
|
|
const second = buildFeed(makeSnapshot(store, config, 7, 4000), options, 'https://status.example');
|
|
assert.equal((first.match(/<item>/g) || []).length, 2);
|
|
assert.match(first, /Cause &lt;error&gt; &amp; fix/);
|
|
assert.doesNotMatch(first, /<error>|signature|apiKey|cookie/);
|
|
assert.deepEqual(first.match(/<guid.*?<\/guid>/g), second.match(/<guid.*?<\/guid>/g));
|
|
assert.match(first, /#incident-1/);
|
|
assert.equal(xmlEscape('🙂\u0000\ud800 <'), '🙂 <');
|
|
} finally { store.close(); }
|
|
});
|
|
|
|
test('RSS filtering includes global announcements but excludes other services and event types', () => {
|
|
const store = openStore(':memory:');
|
|
try {
|
|
store.createIncident(manual, 1000);
|
|
store.createIncident({ ...manual, serviceId: 'board', titleEn: 'Board only' }, 2000);
|
|
store.createIncident({ ...manual, serviceId: null, titleEn: 'Global report' }, 3000);
|
|
store.createMaintenance({ titleRu: 'Работы', titleEn: 'Maintenance only', messageRu: 'Обновление', messageEn: 'Update', serviceIds: ['pwa'], startsAt: 5000, endsAt: 9000 }, 4000);
|
|
const snapshot = makeSnapshot(store, config, 7, 6000);
|
|
const incidentXml = buildFeed(snapshot, feedOptions(new URLSearchParams('lang=en&services=api&types=incidents'), config.services), 'https://status.example');
|
|
assert.match(incidentXml, /Global report/); assert.doesNotMatch(incidentXml, /Board only|Maintenance only/);
|
|
const maintenanceXml = buildFeed(snapshot, feedOptions(new URLSearchParams('lang=en&services=pwa&types=maintenance'), config.services), 'https://status.example');
|
|
assert.match(maintenanceXml, /Maintenance only/); assert.doesNotMatch(maintenanceXml, /Global report|Timetable/);
|
|
} finally { store.close(); }
|
|
});
|
|
|
|
test('maintenance RSS records announcement and cancellation without inventing a start or completion', () => {
|
|
const store = openStore(':memory:');
|
|
try {
|
|
const id = store.createMaintenance({ titleRu: 'Работы', titleEn: 'Cancelled window', messageRu: 'Текст', messageEn: 'Text', serviceIds: [], startsAt: 5000, endsAt: 9000 }, 1000);
|
|
store.endMaintenance(id, 'cancel', 2000);
|
|
const xml = buildFeed(makeSnapshot(store, config, 7, 10000), feedOptions(new URLSearchParams('lang=en&types=maintenance'), config.services), 'https://status.example');
|
|
assert.equal((xml.match(/<item>/g) || []).length, 2);
|
|
assert.match(xml, /Maintenance cancelled/); assert.doesNotMatch(xml, /window started|window ended|Completed by/);
|
|
} finally { store.close(); }
|
|
});
|
|
|
|
test('feed endpoint is read-only XML, uses the configured canonical URL and excludes demo events', async () => {
|
|
const app = await createStatusServer(loadConfig({ STATUS_DB_PATH: ':memory:', STATUS_PUBLIC_URL: 'https://status.example', PORT: '0', HOST: '127.0.0.1' }), { monitorEnabled: false });
|
|
try {
|
|
await new Promise(resolve => app.server.listen(0, '127.0.0.1', resolve));
|
|
const origin = `http://127.0.0.1:${app.server.address().port}`;
|
|
app.store.createIncident(manual, Date.now());
|
|
const response = await fetch(`${origin}/feed.xml?lang=en&services=api`);
|
|
assert.match(response.headers.get('content-type'), /^application\/rss\+xml/);
|
|
assert.equal(response.headers.get('cache-control'), 'no-store');
|
|
const xml = await response.text();
|
|
assert.match(xml, /https:\/\/status.example/); assert.doesNotMatch(xml, /example-active|demo-active/);
|
|
assert.equal((await fetch(`${origin}/feed.xml?services=missing`)).status, 400);
|
|
assert.equal((await fetch(`${origin}/feed.xml?demo=1`)).status, 400);
|
|
assert.equal((await fetch(`${origin}/feed.xml`, { method: 'POST' })).status, 405);
|
|
assert.equal(await (await fetch(`${origin}/feed.xml`, { method: 'HEAD' })).text(), '');
|
|
} finally { await app.close(); }
|
|
});
|