75 lines
3.8 KiB
JavaScript
75 lines
3.8 KiB
JavaScript
import { test, before, after } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { existsSync, readFileSync } from "node:fs";
|
||
import { createHash } from "node:crypto";
|
||
import { startApi } from "./support/nextcloud.js";
|
||
|
||
// Protection of a server open to the internet, with the limits lowered so the tests reach them:
|
||
// five API requests a minute and an 800 ms time limit.
|
||
let api, base;
|
||
let sent = 0;
|
||
before(async () => {
|
||
api = await startApi({ PSU_API_RATE_LIMIT: "5", PSU_REQUEST_TIMEOUT_MS: "800", PSU_SOURCE_TIMEOUT_MS: "5000" });
|
||
base = api.base;
|
||
});
|
||
after(() => api?.stop());
|
||
const apiGet = (path) => {
|
||
sent++;
|
||
return fetch(base + path);
|
||
};
|
||
|
||
test("Защита: заголовки безопасности у страницы и API", async () => {
|
||
for (const path of ["/", "/api/health"]) {
|
||
const response = await fetch(base + path);
|
||
await response.arrayBuffer();
|
||
const csp = response.headers.get("content-security-policy");
|
||
assert.match(csp, /default-src 'self'/, path);
|
||
assert.match(csp, /frame-ancestors 'none'/, path);
|
||
assert.match(csp, /object-src 'none'/, path);
|
||
assert.equal(response.headers.get("x-content-type-options"), "nosniff", path);
|
||
assert.equal(response.headers.get("x-frame-options"), "DENY", path);
|
||
assert.ok(response.headers.get("referrer-policy"), path);
|
||
assert.ok(response.headers.get("permissions-policy"), path);
|
||
// Plain HTTP: no HSTS.
|
||
assert.equal(response.headers.get("strict-transport-security"), null, path);
|
||
}
|
||
});
|
||
|
||
test("Защита: CSP разрешает ровно inline-скрипт темы из собранной страницы", { skip: !existsSync(new URL("../dist/index.html", import.meta.url)) && "нет dist/, сначала npm run build" }, async () => {
|
||
const html = readFileSync(new URL("../dist/index.html", import.meta.url), "utf8");
|
||
const inline = html.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||
const hash = createHash("sha256").update(inline).digest("base64");
|
||
const csp = (await fetch(base + "/")).headers.get("content-security-policy");
|
||
assert.ok(csp.includes(`'sha256-${hash}'`), csp);
|
||
assert.doesNotMatch(csp, /unsafe-inline|unsafe-eval/);
|
||
});
|
||
|
||
test("Защита: запрос, на который сервер не успел ответить, получает 504 timeout", async () => {
|
||
const catalog = await (await apiGet("/api/catalog")).json();
|
||
const slow = catalog.files.find((f) => f.name === "Медленный.docx");
|
||
const started = Date.now();
|
||
const response = await apiGet(`/api/original/${slow.id}`);
|
||
assert.equal(response.status, 504);
|
||
assert.equal((await response.json()).code, "timeout");
|
||
assert.equal(response.headers.get("retry-after"), "30");
|
||
assert.ok(Date.now() - started < 1800, "ответ не ждёт университет");
|
||
// The late answer of the university does not break the server.
|
||
await new Promise((resolve) => setTimeout(resolve, 2200));
|
||
assert.equal((await fetch(base + "/api/health")).status, 200);
|
||
});
|
||
|
||
test("Защита: лимит запросов к API с одного адреса, проверки здоровья не считаются", async () => {
|
||
for (let i = 0; i < 10; i++) {
|
||
assert.equal((await fetch(base + "/api/health")).status, 200);
|
||
assert.equal((await fetch(base + "/api/ready")).status === 429, false);
|
||
}
|
||
while (sent < 5) assert.notEqual((await apiGet("/api/nope")).status, 429);
|
||
const limited = await apiGet("/api/nope");
|
||
assert.equal(limited.status, 429);
|
||
assert.equal((await limited.json()).code, "too_many_requests");
|
||
assert.ok(Number(limited.headers.get("retry-after")) > 0);
|
||
// The page and its files are not API requests and stay available.
|
||
assert.equal((await fetch(base + "/")).status, 200);
|
||
assert.equal((await fetch(base + "/api/health")).status, 200);
|
||
});
|