Files
VibeFinance/backend/test/app.test.js
T

160 lines
6.2 KiB
JavaScript
Raw Permalink Normal View History

/**
* Integratietests voor de gecombineerde app-container: de Express-app serveert
* zowel de API op /api als de gebouwde frontend op /.
*
* Draait zonder database: NODE_ENV=test slaat het schema-initialisatie en
* app.listen() in server.js over, en geen van de geteste routes raakt de db.
*/
import { after, before, describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const INDEX_MARKER = "<div id=\"root\"></div>";
const ASSET_BODY = "console.log('vibefinance');";
const FAVICON_BODY = "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
let server;
let basis;
before(async () => {
// Nagebootste Vite-build: index.html + een asset met hash in de naam.
const staticDir = mkdtempSync(join(tmpdir(), "vibefinance-static-"));
mkdirSync(join(staticDir, "assets"));
writeFileSync(join(staticDir, "index.html"), `<!DOCTYPE html><html><body>${INDEX_MARKER}</body></html>`);
writeFileSync(join(staticDir, "assets", "index-abc12345.js"), ASSET_BODY);
writeFileSync(join(staticDir, "favicon.svg"), FAVICON_BODY);
process.env.NODE_ENV = "test";
process.env.STATIC_DIR = staticDir;
process.env.UPDATE_CHECK_URL = ""; // geen uitgaande call in tests
process.env.JWT_SECRET = "test-secret";
const { default: app } = await import("../src/server.js");
server = app.listen(0);
await new Promise((res) => server.once("listening", res));
basis = `http://127.0.0.1:${server.address().port}`;
});
after(() => server?.close());
describe("API", () => {
it("health geeft status ok", async () => {
const res = await fetch(`${basis}/api/health`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.status, "ok");
assert.equal(typeof body.uptime, "number");
});
it("onbekende API-route geeft 404 als JSON, niet de SPA", async () => {
const res = await fetch(`${basis}/api/bestaat-niet`);
assert.equal(res.status, 404);
assert.match(res.headers.get("content-type") ?? "", /application\/json/);
assert.equal((await res.json()).error, "Endpoint niet gevonden.");
});
it("beschermde route zonder token geeft 401", async () => {
const res = await fetch(`${basis}/api/data`);
assert.equal(res.status, 401);
assert.equal((await res.json()).error, "Niet ingelogd.");
});
it("update-check zonder ingestelde URL meldt niets", async () => {
const res = await fetch(`${basis}/api/update-check`);
const body = await res.json();
assert.equal(res.status, 200);
assert.equal(body.latest, null);
assert.equal(body.disabled, true);
});
// Zonder expliciete header bepaalt de browser zelf hoe lang hij een
// API-antwoord hergebruikt, en dat verschilt per browser.
it("API-antwoorden komen nooit uit de browsercache", async () => {
for (const pad of ["/api/health", "/api/update-check", "/api/bestaat-niet"]) {
const res = await fetch(`${basis}${pad}`);
assert.match(res.headers.get("cache-control") ?? "", /no-store/, pad);
}
});
});
describe("Frontend serveren", () => {
it("root geeft index.html", async () => {
const res = await fetch(`${basis}/`);
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
assert.match(await res.text(), new RegExp(INDEX_MARKER));
});
it("onbekend pad valt terug op index.html (React-routing)", async () => {
const res = await fetch(`${basis}/voortgang`);
assert.equal(res.status, 200);
assert.match(await res.text(), new RegExp(INDEX_MARKER));
});
it("index.html wordt niet gecached", async () => {
const res = await fetch(`${basis}/`);
assert.match(res.headers.get("cache-control") ?? "", /no-store/);
});
it("assets met hash worden lang gecached", async () => {
const res = await fetch(`${basis}/assets/index-abc12345.js`);
assert.equal(res.status, 200);
assert.equal(await res.text(), ASSET_BODY);
assert.match(res.headers.get("cache-control") ?? "", /immutable/);
});
it("favicon wordt geserveerd als svg", async () => {
const res = await fetch(`${basis}/favicon.svg`);
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /image\/svg\+xml/);
assert.equal(await res.text(), FAVICON_BODY);
});
// De naam bevat geen hash, dus een jaar "immutable" zou een oude of
// verkeerde versie onhoudbaar lang in de browsercache vastzetten.
it("favicon wordt niet immutable gecached", async () => {
const res = await fetch(`${basis}/favicon.svg`);
const cache = res.headers.get("cache-control") ?? "";
assert.doesNotMatch(cache, /immutable/);
assert.match(cache, /must-revalidate/);
});
// Anders krijgt de browser index.html met status 200 en content-type
// text/html waar hij een icoon verwacht, en toont hij stil niets.
it("ontbrekend bestand geeft 404 in plaats van index.html", async () => {
for (const pad of ["/favicon.ico", "/apple-touch-icon.png"]) {
const res = await fetch(`${basis}${pad}`);
assert.equal(res.status, 404, pad);
assert.doesNotMatch(await res.text(), new RegExp(INDEX_MARKER), pad);
}
});
});
describe("Security headers", () => {
it("CSP staat Google Fonts en inline styles toe", async () => {
const res = await fetch(`${basis}/`);
const csp = res.headers.get("content-security-policy") ?? "";
assert.match(csp, /style-src [^;]*'unsafe-inline'/);
assert.match(csp, /fonts\.googleapis\.com/);
assert.match(csp, /font-src [^;]*fonts\.gstatic\.com/);
assert.doesNotMatch(csp, /upgrade-insecure-requests/);
});
// express-rate-limit gebruikt per RFC-draft andere headernamen
// (RateLimit-Limit vs. RateLimit); daarom op de hele familie testen.
const heeftRateLimitHeaders = (res) =>
[...res.headers.keys()].some((naam) => naam.startsWith("ratelimit"));
it("statische bestanden krijgen geen rate-limit headers", async () => {
const res = await fetch(`${basis}/assets/index-abc12345.js`);
assert.equal(heeftRateLimitHeaders(res), false);
});
it("de API krijgt wel rate-limit headers", async () => {
const res = await fetch(`${basis}/api/health`);
assert.equal(heeftRateLimitHeaders(res), true);
});
});