feat: implement dashboard with authentication and event logging
- Added dashboard functionality at /status with Basic Auth using DASHBOARD_PASSWORD. - Created server-side event logging to track catalog and university status. - Implemented traffic metrics tracking and display on the dashboard. - Enhanced API tests to cover new dashboard features and authentication. - Introduced CSS and JS for dashboard styling and functionality. - Added tests for dashboard access control and data retrieval.
This commit is contained in:
@@ -26,6 +26,11 @@ test("API: до загрузки индекса сервер жив, но не
|
||||
assert.equal(health.body.ready, false);
|
||||
assert.equal(health.body.client, "127.0.0.1");
|
||||
assert.equal(typeof health.body.apiVersion, "number");
|
||||
// Public health says alive, ready and versions; the rest is on the dashboard.
|
||||
assert.equal(health.body.traffic, undefined);
|
||||
assert.equal(health.body.telegram, undefined);
|
||||
// Without DASHBOARD_PASSWORD the dashboard is off.
|
||||
assert.equal((await get("/status/")).status, 404);
|
||||
const ready = await json("/api/ready");
|
||||
assert.equal(ready.status, 503);
|
||||
assert.equal(ready.body.code, "not_ready");
|
||||
|
||||
@@ -21,13 +21,14 @@ let api;
|
||||
const lines = [];
|
||||
before(async () => {
|
||||
mock.method(console, "log", (line) => lines.push(line));
|
||||
api = await startApi({ PSU_REQUEST_LOG: "on", PSU_API_RATE_LIMIT: "6" });
|
||||
api = await startApi({ PSU_REQUEST_LOG: "on", PSU_API_RATE_LIMIT: "6", DASHBOARD_PASSWORD: "secret" });
|
||||
});
|
||||
after(() => {
|
||||
mock.restoreAll();
|
||||
api?.stop();
|
||||
});
|
||||
const get = (path, headers) => fetch(api.base + path, { headers });
|
||||
const status = async () => (await fetch(api.base + "/status/data.json", { headers: { Authorization: "Basic " + Buffer.from("admin:secret").toString("base64") } })).json();
|
||||
|
||||
test("Метрики: строка журнала на запрос и сводка в /api/health", async () => {
|
||||
const groups = await get("/api/groups?" + new URLSearchParams({ q: "23-БА" }));
|
||||
@@ -51,9 +52,11 @@ test("Метрики: строка журнала на запрос и свод
|
||||
assert.equal(typeof notFound.ms, "number");
|
||||
assert.ok(!logged.some((l) => l.path.startsWith("/api/health")));
|
||||
|
||||
const { traffic } = await (await get("/api/health")).json();
|
||||
const { traffic } = await status();
|
||||
const hour = traffic.lastHour;
|
||||
assert.equal(hour.requests, 4);
|
||||
assert.equal(traffic.series.length, 60);
|
||||
assert.equal(traffic.series.at(-1).requests, 4);
|
||||
assert.equal(hour.notModified, 1);
|
||||
assert.equal(hour.notModifiedShare, 0.25);
|
||||
assert.equal(hour.clientErrors, 1);
|
||||
@@ -71,7 +74,7 @@ test("Метрики: лимит запросов и сбои университ
|
||||
assert.equal((await get(`/api/original/${broken.id}`)).status, 502);
|
||||
// The limit of 6 is reached: 4 earlier requests, the catalog and the original above.
|
||||
assert.equal((await get("/api/nope")).status, 429);
|
||||
const { traffic } = await (await get("/api/health")).json();
|
||||
const { traffic } = await status();
|
||||
const hour = traffic.lastHour;
|
||||
assert.equal(hour.limited, 1);
|
||||
assert.equal(hour.sourceErrors, 1);
|
||||
|
||||
54
test/status.test.js
Normal file
54
test/status.test.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { startApi } from "./support/nextcloud.js";
|
||||
|
||||
// The dashboard at /status: the browser's sign-in window, a lockout after ten wrong passwords,
|
||||
// the page under the strict CSP, and its data.
|
||||
let api;
|
||||
before(async () => {
|
||||
api = await startApi({ DASHBOARD_PASSWORD: "пароль-123" });
|
||||
});
|
||||
after(() => api?.stop());
|
||||
const basic = (user, password) => ({ Authorization: "Basic " + Buffer.from(`${user}:${password}`).toString("base64") });
|
||||
const get = (path, headers = {}) => fetch(api.base + path, { headers });
|
||||
|
||||
test("Дашборд: без пароля — окно входа браузера", async () => {
|
||||
const response = await get("/status/");
|
||||
assert.equal(response.status, 401);
|
||||
assert.match(response.headers.get("www-authenticate"), /^Basic realm=/);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.equal(response.headers.get("x-robots-tag"), "noindex");
|
||||
assert.equal((await get("/status/data.json")).status, 401);
|
||||
});
|
||||
|
||||
test("Дашборд: с паролем — страница, её файлы и данные", async () => {
|
||||
const auth = basic("любой", "пароль-123");
|
||||
const page = await get("/status/", auth);
|
||||
assert.equal(page.status, 200);
|
||||
const html = await page.text();
|
||||
assert.match(html, /<script type="module" src="status\.js"><\/script>/);
|
||||
// Nothing inline: the strict CSP allows only the server's own files.
|
||||
assert.doesNotMatch(html, /<script>|style=/);
|
||||
assert.match(page.headers.get("content-security-policy"), /script-src 'self'/);
|
||||
assert.equal((await get("/status/status.js", auth)).status, 200);
|
||||
assert.equal((await get("/status/status.css", auth)).status, 200);
|
||||
|
||||
await get("/api/groups?q=23");
|
||||
const data = await (await get("/status/data.json", auth)).json();
|
||||
assert.equal(typeof data.server.apiVersion, "number");
|
||||
assert.ok(Array.isArray(data.server.waiting));
|
||||
assert.ok(data.catalog.files > 0);
|
||||
assert.equal(data.directory.documents, 2);
|
||||
assert.equal(data.traffic.series.length, 60);
|
||||
assert.ok(Array.isArray(data.events));
|
||||
assert.equal(typeof data.diagnostics.problems, "number");
|
||||
assert.equal(data.diagnostics.failed.length, 2);
|
||||
assert.equal(data.telegram.enabled, false);
|
||||
});
|
||||
|
||||
test("Дашборд: десять неверных паролей — блокировка на 10 минут", async () => {
|
||||
for (let i = 0; i < 10; i++) assert.equal((await get("/status/", basic("admin", "не тот"))).status, 401);
|
||||
const locked = await get("/status/", basic("admin", "пароль-123"));
|
||||
assert.equal(locked.status, 429);
|
||||
assert.ok(Number(locked.headers.get("retry-after")) > 0);
|
||||
});
|
||||
@@ -7,7 +7,8 @@ import { startApi, fixture } from "./support/nextcloud.js";
|
||||
// rebuilt only for a changed document, and messages to the owner in Telegram (a stand-in here).
|
||||
let api, telegram, watch;
|
||||
const messages = [];
|
||||
const health = async () => (await fetch(api.base + "/api/health")).json();
|
||||
// What the dashboard shows (/status, behind its password).
|
||||
const health = async () => (await fetch(api.base + "/status/data.json", { headers: { Authorization: "Basic " + Buffer.from("admin:secret").toString("base64") } })).json();
|
||||
const lastMessage = () => messages.at(-1)?.text || "";
|
||||
|
||||
before(async () => {
|
||||
@@ -29,6 +30,7 @@ before(async () => {
|
||||
PSU_ALERT_SOURCE_DOWN_MS: "0",
|
||||
// Every check reaches the stand-in, however soon after the last one.
|
||||
PSU_MIN_REFRESH_MS: "0",
|
||||
DASHBOARD_PASSWORD: "secret",
|
||||
});
|
||||
watch = await import("../server/watch.js");
|
||||
});
|
||||
@@ -109,5 +111,13 @@ test("Наблюдение: университет не отвечает и сн
|
||||
await watch.watchOnce();
|
||||
assert.equal(messages.length, 5);
|
||||
assert.match(lastMessage(), /снова отвечает/);
|
||||
assert.equal((await health()).watch.lastError, null);
|
||||
const { watch: after, events } = await health();
|
||||
assert.equal(after.lastError, null);
|
||||
// The dashboard's feed, newest first: everything above, republished-as-is included.
|
||||
assert.deepEqual(
|
||||
events.map((e) => e.kind),
|
||||
["source_up", "source_down", "republished", "republished", "new"],
|
||||
);
|
||||
assert.equal(events[2].text, "без новых подозрительных ячеек");
|
||||
assert.match(events[3].text, /новых подозрительных ячеек: 1/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user