/** * 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 = "
"; const ASSET_BODY = "console.log('vibefinance');"; const FAVICON_BODY = ""; 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"), `${INDEX_MARKER}`); 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); }); });