Migratie naar 2-container opzet (app + database)
De Express-app serveert nu zelf de gebouwde Vite-frontend op / met een SPA-fallback naar index.html; de aparte nginx-container is vervallen. Bedoeld voor self-hosting op een Raspberry Pi: één image, één origin, geen interne proxy en geen CORS tussen frontend en API. Docker - Nieuwe multi-stage Dockerfile in de root met targets test, development en production; frontend/Dockerfile, frontend/nginx-spa.conf en backend/Dockerfile verwijderd - docker-compose.yml: alleen app + postgres, app publiceert APP_PORT (standaard 3400) naar containerpoort 3001 - docker-compose.dev.yml: dev-container draait API (node --watch) en de Vite dev-server samen via docker/dev-entrypoint.sh, hot reload intact - .dockerignore, .env.example, .gitignore en .gitattributes toegevoegd Backend - express.static + SPA-fallback op STATIC_DIR (default /app/public), met immutable caching voor gehashte assets en no-store voor index.html - Rate limiting alleen nog op /api — statische bestanden liepen anders na 200 requests tegen de limiet aan - Helmet CSP toegespitst op wat de frontend laadt (Google Fonts, inline styles, base64-avatars); upgrade-insecure-requests uit voor intern http - Schema-init verplaatst naar initDb(), niet meer bij import van db.js Tests en CI/CD - backend/test/app.test.js: 11 tests (node --test) voor API-routes, SPA-fallback, cache- en security-headers, zonder database - test.ps1 / test.sh: backend-tests, productiebuild en smoketest op de echte app + postgres stack - azure-pipelines.yml: Test -> Build (incl. smoketest) -> Publish (multi-arch amd64 + arm64, alleen op main/master of een tag) Scripts en documentatie - dev.ps1/dev.sh met targets app en postgres, acties logs en test; dev.ps1 up wist de dev-database niet langer (alleen dev.ps1 down) - release.ps1/release.sh bouwen één image voor amd64 + arm64 en draaien de testsuite vóór het publiceren - README.md en BEHEER.md bijgewerkt voor de nieuwe structuur Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3c67381de9
commit
bfec3a25f3
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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');";
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
it("statische bestanden krijgen geen rate-limit headers", async () => {
|
||||
const res = await fetch(`${basis}/assets/index-abc12345.js`);
|
||||
assert.equal(res.headers.get("ratelimit-limit"), null);
|
||||
});
|
||||
|
||||
it("de API krijgt wel rate-limit headers", async () => {
|
||||
const res = await fetch(`${basis}/api/health`);
|
||||
assert.notEqual(res.headers.get("ratelimit-limit"), null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user