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:
Jordy Hoogeveen
2026-09-04 11:51:29 +02:00
co-authored by Claude Opus 5
parent 3c67381de9
commit bfec3a25f3
29 changed files with 1168 additions and 428 deletions
+18
View File
@@ -0,0 +1,18 @@
# Build-context zo klein mogelijk houden
**/node_modules
**/dist
.git
.gitignore
.claude
*.md
!README.md
.env
.env.*
docker-compose*.yml
dev.ps1
dev.sh
release.ps1
release.sh
test.ps1
test.sh
azure-pipelines.yml
+21
View File
@@ -0,0 +1,21 @@
# ── VibeFinance omgevingsvariabelen ──────────────────────────────────────────
# Kopieer naar .env en vul in: cp .env.example .env
# Database (postgres-container)
POSTGRES_DB=vibefinance
POSTGRES_USER=vibefinance
POSTGRES_PASSWORD=kies-een-sterk-wachtwoord
# App
# Poort waarop de app op de host draait (container luistert intern op 3001)
APP_PORT=3400
# Geheim voor het ondertekenen van JWT's — verplicht in productie
JWT_SECRET=vervang-dit-door-een-lange-random-string
JWT_EXPIRES=7d
# Origin die de API mag aanspreken; bij één container is dat de app zelf
CORS_ORIGIN=http://localhost:3400
# Update-notificaties (leeg = melding uit)
UPDATE_CHECK_URL=https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest
# Alleen nodig als de Gitea-repo niet publiek leesbaar is
# UPDATE_CHECK_TOKEN=
+11
View File
@@ -0,0 +1,11 @@
# Zorg dat shell-scripts en Docker-bestanden altijd met LF worden uitgechecked:
# met CRLF faalt het shebang in de container ("bad interpreter").
* text=auto
*.sh text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
docker/* text eol=lf
# PowerShell mag CRLF houden (Windows-tooling)
*.ps1 text eol=crlf
+13
View File
@@ -0,0 +1,13 @@
# Dependencies
node_modules/
# Build-output
frontend/dist/
# Omgevingsvariabelen (gebruik .env.example als sjabloon)
.env
.env.local
# Logs en rommel
*.log
.DS_Store
+92 -31
View File
@@ -1,28 +1,59 @@
# VibeFinance — Beheer # VibeFinance — Beheer
De app draait in **2 containers**: `app` (Express API + gebouwde frontend) en
`postgres`. Eén image, één origin — geen aparte webserver en geen interne proxy.
--- ---
## Ontwikkeling ## Ontwikkeling
```powershell ```powershell
.\dev.ps1 # build + start (standaard) .\dev.ps1 # build + start (standaard)
.\dev.ps1 build # alleen bouwen .\dev.ps1 build # alleen bouwen
.\dev.ps1 build -Target backend # alleen backend bouwen .\dev.ps1 up -Target app # alleen de app herbouwen + herstarten
.\dev.ps1 build -Target frontend # alleen frontend bouwen .\dev.ps1 up -Target postgres # alleen de database herstarten
.\dev.ps1 up -Target backend # alleen backend herbouwen + herstarten .\dev.ps1 logs # logs van de app volgen
.\dev.ps1 up -Target frontend # alleen frontend herbouwen + herstarten .\dev.ps1 test # testsuite draaien
.\dev.ps1 push # dev-images naar registry pushen .\dev.ps1 down # stoppen én volumes wissen
.\dev.ps1 push -Target backend # alleen backend pushen .\dev.ps1 push # dev-image naar registry pushen
.\dev.ps1 push -Target frontend # alleen frontend pushen
``` ```
> Op macOS/Linux: gebruik `./dev.sh` met dezelfde argumenten. > Op macOS/Linux: gebruik `./dev.sh` met dezelfde argumenten.
> ⚠️ `.\dev.ps1 up` (beide services) en `.\dev.ps1 down` voeren intern `docker compose down -v` uit — dit wist de development-database. Gebruik voor een herstart zonder dataverlies: In dev draait één container beide processen (zie `docker/dev-entrypoint.sh`):
| Adres | Wat |
|---|---|
| http://localhost | Vite dev-server met hot reload |
| http://localhost:3001/api/health | de API rechtstreeks |
Vite proxyt `/api` naar `localhost:3001` binnen dezelfde container.
> ⚠️ Alleen `.\dev.ps1 down` wist de development-database (`docker compose down -v`).
> `.\dev.ps1 up` doet een gewone `down` en laat het volume staan.
---
## Testen
```powershell
.\test.ps1 # backend-tests + productiebuild + smoketest
.\test.ps1 -Unit # alleen de backend-tests
.\test.ps1 -NoSmoke # tests + build, zonder de stack te starten
```
Alles loopt in Docker; Node op de host is niet nodig.
| Stap | Wat er gebeurt |
|---|---|
| Backend-tests | `Dockerfile` target `test` → `node --test` in `backend/test/`, zonder database |
| Productiebuild | `Dockerfile` target `production` — faalt de Vite-build, faalt deze stap |
| Smoketest | app + postgres starten en `/api/health` en `/` opvragen |
Losse backend-tests draaien in een lopende dev-container:
```bash ```bash
docker compose -f docker-compose.dev.yml down docker compose -f docker-compose.dev.yml exec app npm test
docker compose -f docker-compose.dev.yml up -d --build
``` ```
--- ---
@@ -31,16 +62,38 @@ docker compose -f docker-compose.dev.yml up -d --build
```powershell ```powershell
# 1. Versienummer in package.json van "x.x.x-dev" naar "x.x.x" # 1. Versienummer in package.json van "x.x.x-dev" naar "x.x.x"
# 2. # 2. Changelog.md bijwerken — die sectie wordt de release-notes
.\release.ps1 # bouw, push naar registry, maak Git-tag aan # 3. Token met release-rechten zetten
.\release.ps1 -DryRun # droogloop — toont alle stappen zonder uit te voeren $env:GITEA_TOKEN = "<token>"
.\release.ps1 -NoBuild # sla docker build over, push bestaande images
# 3. Versienummer in package.json naar "x.x.(x+1)-dev" voor volgende cyclus .\release.ps1 # test, bouw (amd64+arm64), push, tag, Gitea-release
.\release.ps1 -DryRun # droogloop — toont alle stappen zonder uit te voeren
.\release.ps1 -NoBuild # sla docker build over, alleen tag + release
.\release.ps1 -SkipTests # sla de testsuite over (niet aanbevolen)
# 4. Versienummer in package.json naar "x.x.(x+1)-dev" voor volgende cyclus
``` ```
Het script bouwt één image voor `linux/amd64` en `linux/arm64`, zodat dezelfde
tag op een Raspberry Pi en op een x86-server werkt.
--- ---
## CI/CD (Azure DevOps)
`azure-pipelines.yml` doet in drie stages hetzelfde als de lokale scripts:
| Stage | Wat |
|---|---|
| `Test` | `docker build --target test` + `docker run` → backend-tests |
| `Build` | productie-image bouwen en smoketesten tegen een postgres-container |
| `Publish` | multi-arch image (amd64 + arm64) pushen naar de registry |
`Publish` loopt alleen op `main`/`master` of een tag. Vul vóór het eerste gebruik
de variabele `dockerRegistryServiceConnection` in met de naam van je Docker
Registry service connection in Azure DevOps.
---
## Docker ## Docker
```bash ```bash
@@ -71,29 +124,37 @@ docker exec -i vibefinance_postgres psql -U vibefinance -d vibefinance < backup-
--- ---
## Architectuur — nginx proxy ## Architectuur — één app-container
De frontend-container (nginx) serveert de React-app en proxyt `/api/`-verzoeken intern door naar de backend-container op poort 3001. Dit is geconfigureerd in `frontend/nginx-spa.conf`: De Express-app serveert zowel de API als de gebouwde frontend:
```nginx | Verzoek | Wat de app doet |
location /api/ { |---|---|
proxy_pass http://backend:3001; | `/api/...` | REST API (rate limited) |
} | `/assets/...` | statische build uit `STATIC_DIR`, 1 jaar `immutable` gecached |
``` | al het overige | `index.html` — fallback voor React-routing |
> Ontbreekt dit blok, dan krijgen gebruikers "Kan geen verbinding maken met de server" bij het inloggen — ook al draaien alle containers. `STATIC_DIR` staat standaard op `/app/public`; daar zet de `Dockerfile` de
Vite-build neer. Bestaat die map niet (bijv. in dev), dan serveert de app
alleen de API en logt dat bij het starten.
> Ontbreekt de build in het image, dan krijg je op `/` een 404 en werkt alleen
> `/api/...`. Controleer dan de `frontend-build` stage in de `Dockerfile`.
--- ---
## Logs en diagnose ## Logs en diagnose
```bash ```bash
# Live logs (dev) # Live logs (dev) — beide processen van de app-container
docker compose -f docker-compose.dev.yml logs -f docker compose -f docker-compose.dev.yml logs -f app
# Live logs (productie) # Live logs (productie)
docker compose logs -f docker compose logs -f
# Shell in de app-container (dev)
docker compose -f docker-compose.dev.yml exec app sh
# Status containers # Status containers
docker compose ps docker compose ps
@@ -157,7 +218,8 @@ Rollen:
- [ ] `POSTGRES_PASSWORD` instellen op een sterk wachtwoord - [ ] `POSTGRES_PASSWORD` instellen op een sterk wachtwoord
- [ ] `JWT_SECRET` aanpassen (genereer met `openssl rand -hex 64`) - [ ] `JWT_SECRET` aanpassen (genereer met `openssl rand -hex 64`)
- [ ] `CORS_ORIGIN` instellen op je domeinnaam - [ ] `CORS_ORIGIN` instellen op je domeinnaam
- [ ] `FRONTEND_PORT` aanpassen indien poort 3300 al in gebruik is - [ ] `APP_PORT` aanpassen indien poort 3400 al in gebruik is
- [ ] Testsuite groen (`test.ps1` / `./test.sh`)
- [ ] TLS afhandelen via een externe reverse proxy (bijv. Nginx Proxy Manager, Caddy, Traefik) - [ ] TLS afhandelen via een externe reverse proxy (bijv. Nginx Proxy Manager, Caddy, Traefik)
--- ---
@@ -165,8 +227,7 @@ Rollen:
## Registry ## Registry
``` ```
vibehoogie.duckdns.org:3000/vibe/vibefinance-backend vibehoogie.duckdns.org/vibe/vibefinance-app (frontend + backend in één image)
vibehoogie.duckdns.org:3000/vibe/vibefinance-frontend
Tags: Tags:
latest productie — meest recente release latest productie — meest recente release
@@ -177,7 +238,7 @@ Tags:
Inloggen op registry: Inloggen op registry:
```bash ```bash
docker login vibehoogie.duckdns.org:3000 docker login vibehoogie.duckdns.org
``` ```
--- ---
+38
View File
@@ -1,5 +1,43 @@
# Changelog # Changelog
## [0.1.6] - 2026-09-04
### Gewijzigd
- **Migratie naar 2 containers**: de Express-app serveert nu zelf de gebouwde
frontend op `/` (met SPA-fallback naar `index.html`); de aparte nginx-container
is vervallen. Eén image, één origin, geen interne proxy — bedoeld voor
self-hosting op een Raspberry Pi
- Nieuwe multi-stage `Dockerfile` in de root met targets `test`, `development`
en `production`; `frontend/Dockerfile`, `frontend/nginx-spa.conf` en
`backend/Dockerfile` zijn verwijderd
- `docker-compose.yml` bevat alleen nog `app` en `postgres`; de app publiceert
`APP_PORT` (standaard 3400) naar containerpoort 3001
- Dev-stack draait beide processen in één container (`docker/dev-entrypoint.sh`):
API met `node --watch` en de Vite dev-server met hot reload
- Rate limiting geldt alleen nog voor `/api` — statische bestanden werden anders
na 200 requests geblokkeerd
- Content-Security-Policy via Helmet toegespitst op wat de frontend laadt
(Google Fonts, inline styles, base64-avatars)
- Assets met hash worden een jaar `immutable` gecached, `index.html` nooit
- Schema-initialisatie zit nu in `initDb()` in plaats van bij het importeren van
`db.js`, zodat de app zonder database te importeren is (tests)
- `dev.ps1`, `release.ps1` en de shell-wrappers werken met één app-image;
`dev.ps1 up` wist de dev-database niet langer (alleen `dev.ps1 down`)
- `release.ps1` draait de testsuite vóór het publiceren en bouwt voor
`linux/amd64` + `linux/arm64`
### Toegevoegd
- Testsuite: `backend/test/app.test.js` (11 tests met `node --test`) voor de
API-routes, SPA-fallback, cache-headers en security-headers
- `test.ps1` / `test.sh`: backend-tests, productiebuild en smoketest op de
echte 2-container stack
- `azure-pipelines.yml`: CI/CD met stages Test → Build (incl. smoketest) →
Publish (multi-arch push, alleen op main/master of een tag)
- `.env.example`, `.dockerignore` en `.gitignore`
- `STATIC_DIR` als omgevingsvariabele (map met de frontend-build)
---
## [0.1.5] - 2026-09-04 ## [0.1.5] - 2026-09-04
### Toegevoegd ### Toegevoegd
+80
View File
@@ -0,0 +1,80 @@
# ╔══════════════════════════════════════════════════════════════════════════╗
# ║ VibeFinance — één app-image: Express API + gebouwde Vite-frontend ║
# ║ ║
# ║ Targets: ║
# ║ test backend-tests (npm test, geen database nodig) ║
# ║ development API (node --watch) + Vite dev-server, hot reload ║
# ║ production API die de statische build op / serveert ║
# ╚══════════════════════════════════════════════════════════════════════════╝
# ── Stage 1: frontend dependencies ────────────────────────────────────────────
FROM node:22-alpine AS frontend-deps
WORKDIR /build/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install && npm cache clean --force
# ── Stage 2: frontend bouwen (Vite → dist) ────────────────────────────────────
FROM frontend-deps AS frontend-build
ARG VITE_API_URL=/api
ARG VITE_APP_VERSION=dev
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_APP_VERSION=$VITE_APP_VERSION
COPY frontend/ ./
RUN npm run build
# ── Stage 3: backend dependencies ─────────────────────────────────────────────
FROM node:22-alpine AS backend-deps
WORKDIR /app
COPY backend/package.json backend/package-lock.json* ./
RUN npm install && npm cache clean --force
# ── Stage 4: productie-dependencies (zonder devDependencies) ──────────────────
FROM backend-deps AS backend-prod-deps
RUN npm prune --omit=dev
# ── Stage 5: tests (backend, zonder database) ─────────────────────────────────
FROM backend-deps AS test
WORKDIR /app
ENV NODE_ENV=test
COPY backend/ ./
CMD ["npm", "test"]
# ── Stage 6: development (API + Vite samen, hot reload) ───────────────────────
FROM node:22-alpine AS development
WORKDIR /app
ENV NODE_ENV=development
COPY --from=backend-deps /app/node_modules ./node_modules
COPY --from=frontend-deps /build/frontend/node_modules ./frontend/node_modules
COPY backend/ ./
COPY frontend/ ./frontend/
COPY docker/dev-entrypoint.sh /usr/local/bin/dev-entrypoint.sh
RUN chmod +x /usr/local/bin/dev-entrypoint.sh
# 5173 = Vite dev-server (browser), 3001 = API
EXPOSE 5173 3001
CMD ["/usr/local/bin/dev-entrypoint.sh"]
# ── Stage 7: production (minimale image) ──────────────────────────────────────
FROM node:22-alpine AS production
LABEL maintainer="VibeFinance"
ARG APP_VERSION=dev
ENV NODE_ENV=production
ENV APP_VERSION=$APP_VERSION
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=backend-prod-deps /app/node_modules ./node_modules
COPY backend/package.json ./
COPY backend/src/ ./src/
# De frontend-build wordt door de backend geserveerd (config.STATIC_DIR)
COPY --from=frontend-build /build/frontend/dist ./public/
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3001/api/health || exit 1
CMD ["node", "src/server.js"]
+108 -25
View File
@@ -27,13 +27,16 @@ Deze applicatie is volledig ontworpen en ontwikkeld met behulp van [Claude.ai](h
> Vereisten: Docker Engine 24+ en Docker Compose v2+ > Vereisten: Docker Engine 24+ en Docker Compose v2+
De app draait in **2 containers**: `app` (Express API + gebouwde frontend) en
`postgres` (database).
**`.env`** — kopieer `.env.example` en vul in: **`.env`** — kopieer `.env.example` en vul in:
```env ```env
POSTGRES_USER=vibefinance POSTGRES_USER=vibefinance
POSTGRES_PASSWORD=kies_een_sterk_wachtwoord POSTGRES_PASSWORD=kies_een_sterk_wachtwoord
JWT_SECRET=minimaal_64_willekeurige_tekens JWT_SECRET=minimaal_64_willekeurige_tekens
FRONTEND_PORT=3300 APP_PORT=3400
``` ```
```bash ```bash
@@ -42,10 +45,14 @@ openssl rand -hex 64
``` ```
```bash ```bash
cp .env.example .env # en vul de waarden in
docker compose pull && docker compose up -d docker compose pull && docker compose up -d
``` ```
Navigeer naar `http://<server-ip>:3300` en registreer je account. De **eerste** geregistreerde gebruiker krijgt automatisch de Admin-rol. Navigeer naar `http://<server-ip>:3400` en registreer je account. De **eerste** geregistreerde gebruiker krijgt automatisch de Admin-rol.
De container luistert intern op poort 3001 en serveert daar zowel de frontend
(op `/`) als de API (op `/api`). `APP_PORT` bepaalt de poort op de host.
### Development (hot reload) ### Development (hot reload)
@@ -53,9 +60,45 @@ Navigeer naar `http://<server-ip>:3300` en registreer je account. De **eerste**
.\dev.ps1 .\dev.ps1
``` ```
Frontend bereikbaar op **http://localhost** (poort 80 → Vite dev server op 5173). Eén dev-container draait de API (`node --watch`) én de Vite dev-server samen:
> **Let op:** `.\dev.ps1 up` en `.\dev.ps1 down` wissen de development-database (volumes). Gebruik voor een gewone herstart `docker compose -f docker-compose.dev.yml down` gevolgd door `docker compose -f docker-compose.dev.yml up -d --build`. | Adres | Wat |
|---|---|
| http://localhost | de app in de browser (Vite, hot reload) |
| http://localhost:3001/api/health | de API rechtstreeks |
Vite proxyt `/api` naar de API in dezelfde container, dus je hebt geen CORS-
uitzonderingen nodig. Handige acties:
```powershell
.\dev.ps1 up -Target app # alleen de app herbouwen en herstarten
.\dev.ps1 logs # logs van de app volgen
.\dev.ps1 down # stoppen én volumes wissen (dev-database weg)
.\dev.ps1 test # testsuite draaien
```
> `.\dev.ps1 up` laat de dev-database staan; alleen `.\dev.ps1 down` wist de volumes.
### Testen
```powershell
.\test.ps1 # backend-tests + productiebuild + smoketest
.\test.ps1 -Unit # alleen de backend-tests
.\test.ps1 -NoSmoke # tests + build, zonder de stack te starten
```
Alles loopt in Docker — Node op de host is niet nodig. De backend-tests draaien
zonder database via het `test`-target in de `Dockerfile`.
### Productie release
```powershell
$env:GITEA_TOKEN = "<token met release-rechten>"
.\release.ps1
```
Draait de tests, bouwt het app-image voor `amd64` + `arm64` (Raspberry Pi),
pusht naar de registry, zet de Git-tag en publiceert de Gitea-release.
--- ---
@@ -75,22 +118,28 @@ Frontend bereikbaar op **http://localhost** (poort 80 → Vite dev server op 517
``` ```
vibefinance/ vibefinance/
├── docker-compose.yml # Productie orchestratie (pre-built images) ├── Dockerfile # Multi-stage: frontend build → app-image
├── docker-compose.dev.yml # Standalone dev stack (hot reload) ├── .dockerignore
├── docker-compose.yml # Productie: app + postgres
├── docker-compose.dev.yml # Dev: app (API + Vite) + postgres
├── azure-pipelines.yml # CI/CD: test → build+smoketest → publish
├── .env.example # Kopieer naar .env en vul in ├── .env.example # Kopieer naar .env en vul in
├── package.json # Versienummer — single source of truth ├── package.json # Versienummer — single source of truth
├── dev.ps1 # Dev script (bouwen, starten, pushen) ├── dev.ps1 / dev.sh # Dev script (bouwen, starten, logs, pushen)
├── test.ps1 / test.sh # Testsuite (unit + build + smoketest)
├── release.ps1 / release.sh # Release (test, push, tag, Gitea-release)
│ │
├── frontend/ # React / Vite SPA ├── docker/
│ ├── Dockerfile # Multi-stage: dev → build → nginx │ └── dev-entrypoint.sh # Start API + Vite samen in de dev-container
│ ├── nginx-spa.conf # SPA fallback + /api proxy naar backend │
│ ├── vite.config.js ├── frontend/ # React / Vite SPA (build → dist)
│ ├── vite.config.js # /api proxy naar de API in dezelfde container
│ └── src/ │ └── src/
│ ├── App.jsx # Root – wiring + idle-logout │ ├── App.jsx # Root – wiring + idle-logout
│ ├── main.jsx │ ├── main.jsx
│ ├── constants/ # Kleuren, tabs, begindata │ ├── constants/ # Kleuren, tabs, begindata
│ ├── utils/ # Formatters, helpers │ ├── utils/ # Formatters, helpers
│ ├── hooks/ # useTheme │ ├── hooks/ # useTheme, useUpdateCheck
│ ├── context/ # AppContext (global state + API sync) │ ├── context/ # AppContext (global state + API sync)
│ ├── components/ │ ├── components/
│ │ ├── ui/ # Primitieve UI (VCard, DonutChart…) │ │ ├── ui/ # Primitieve UI (VCard, DonutChart…)
@@ -104,25 +153,39 @@ vibefinance/
│ ├── SchuldTab.jsx │ ├── SchuldTab.jsx
│ └── VoortgangTab.jsx │ └── VoortgangTab.jsx
│ │
└── backend/ # Express REST API └── backend/ # Express REST API + statische frontend
├── Dockerfile # Multi-stage: dev → production (non-root) ├── test/ # node --test integratietests
└── src/ └── src/
├── server.js # Entry point ├── server.js # Entry point: API op /api, SPA op /
├── db.js # PostgreSQL connectie + schema migraties ├── db.js # PostgreSQL pool + initDb() schema-migraties
├── config/ # Omgevingsvariabelen ├── config/ # Omgevingsvariabelen (incl. STATIC_DIR)
├── constants/ # Begindata (initialData.js) ├── constants/ # Begindata (initialData.js)
├── middleware/ # auth.js, errorHandler.js ├── middleware/ # auth.js, errorHandler.js
├── routes/ # auth, data, users, health ├── routes/ # auth, data, users, health, update-check
└── stores/ # userStore, dataStore └── stores/ # userStore, dataStore
``` ```
**Stack:** React 18 · Vite · Node.js 22 · Express 4 · PostgreSQL 18 · JWT · bcrypt · Docker Compose v2 **Stack:** React 18 · Vite · Node.js 22 · Express 4 · PostgreSQL 18 · JWT · bcrypt · Docker Compose v2
### Waarom 2 containers
De frontend werd eerder door een eigen nginx-container geserveerd. Voor
self-hosting op een Raspberry Pi is dat een container te veel: de Express-app
serveert de statische build nu zelf, dus er is geen aparte webserver en geen
interne proxy meer nodig. Dat betekent ook: één origin, dus geen CORS tussen
frontend en API.
| Verzoek | Wat de app doet |
|---|---|
| `/api/...` | REST API (rate limited) |
| `/assets/...` | Statische build, 1 jaar `immutable` gecached |
| al het overige | `index.html` — fallback voor React-routing |
--- ---
## 🔌 API ## 🔌 API
Beveiligde endpoints vereisen `Authorization: Bearer <token>`. Rate limiting: 20 req/15 min op auth-routes, 200 req/15 min globaal. Beveiligde endpoints vereisen `Authorization: Bearer <token>`. Rate limiting: 20 req/15 min op auth-routes, 200 req/15 min op de rest van `/api` (statische bestanden zijn uitgezonderd).
| Route | Methoden | Omschrijving | | Route | Methoden | Omschrijving |
|---|---|---| |---|---|---|
@@ -133,6 +196,7 @@ Beveiligde endpoints vereisen `Authorization: Bearer <token>`. Rate limiting: 20
| `/api/data` | GET, PUT, DELETE | Financiële data per gebruiker | | `/api/data` | GET, PUT, DELETE | Financiële data per gebruiker |
| `/api/users` | GET, POST, PATCH, DELETE | Gebruikersbeheer (Admin only) | | `/api/users` | GET, POST, PATCH, DELETE | Gebruikersbeheer (Admin only) |
| `/api/health` | GET | Status + uptime | | `/api/health` | GET | Status + uptime |
| `/api/update-check` | GET | Laatste release (voor de update-melding) |
--- ---
@@ -150,6 +214,9 @@ Beveiligde endpoints vereisen `Authorization: Bearer <token>`. Rate limiting: 20
> PostgreSQL 18 gebruikt `PGDATA=/var/lib/postgresql/18/docker` — het volume is daarom gemount op `/var/lib/postgresql` (niet `/data`). > PostgreSQL 18 gebruikt `PGDATA=/var/lib/postgresql/18/docker` — het volume is daarom gemount op `/var/lib/postgresql` (niet `/data`).
Het schema wordt bij het starten van de app aangemaakt/bijgewerkt via
`initDb()` in `backend/src/db.js`.
--- ---
## ⚙️ Omgevingsvariabelen ## ⚙️ Omgevingsvariabelen
@@ -161,8 +228,12 @@ Beveiligde endpoints vereisen `Authorization: Bearer <token>`. Rate limiting: 20
| `POSTGRES_USER` | `vibefinance` | Database gebruikersnaam | | `POSTGRES_USER` | `vibefinance` | Database gebruikersnaam |
| `POSTGRES_DB` | `vibefinance` | Database naam | | `POSTGRES_DB` | `vibefinance` | Database naam |
| `JWT_EXPIRES` | `7d` | Geldigheidsduur token | | `JWT_EXPIRES` | `7d` | Geldigheidsduur token |
| `CORS_ORIGIN` | — | Externe domeinnaam | | `CORS_ORIGIN` | `http://localhost` | Externe domeinnaam (één origin, dus zelden nodig) |
| `FRONTEND_PORT` | `3300` | Externe poort frontend | | `APP_PORT` | `3400` | Poort op de host (container luistert op 3001) |
| `PORT` | `3001` | Poort binnen de container |
| `STATIC_DIR` | `/app/public` | Map met de frontend-build |
| `UPDATE_CHECK_URL` | Gitea releases-API | Bron update-melding (leeg = uit) |
| `UPDATE_CHECK_TOKEN` | — | Alleen voor een niet-publieke repo |
--- ---
@@ -170,11 +241,23 @@ Beveiligde endpoints vereisen `Authorization: Bearer <token>`. Rate limiting: 20
- Wachtwoorden gehasht met bcrypt (cost factor 12) - Wachtwoorden gehasht met bcrypt (cost factor 12)
- JWT authenticatie met instelbare vervaltijd - JWT authenticatie met instelbare vervaltijd
- Backend uitsluitend bereikbaar via nginx proxy (`/api/` in `nginx-spa.conf`) - Frontend en API op één origin — geen interne proxy, geen CORS-uitzonderingen
- Content-Security-Policy via Helmet, toegespitst op wat de app laadt
- Database bereikbaar alleen binnen het interne Docker netwerk - Database bereikbaar alleen binnen het interne Docker netwerk
- Containers draaien als non-root - App-container draait als non-root
- Helmet security headers - Rate limiting op `/api` (statische bestanden uitgezonderd)
- Rate limiting op alle endpoints
---
## 🧪 Testen
| Laag | Wat | Commando |
|---|---|---|
| Backend | API-routes, SPA-fallback, cache- en security-headers | `.\test.ps1 -Unit` |
| Build | Vite-build + productie-image | `.\test.ps1 -NoSmoke` |
| Integratie | Smoketest op de echte app + postgres stack | `.\test.ps1` |
In CI draaien dezelfde stappen via `azure-pipelines.yml`.
--- ---
+195
View File
@@ -0,0 +1,195 @@
# ╔══════════════════════════════════════════════════════════════════════════╗
# ║ VibeFinance CI/CD — 2-container opzet ║
# ║ ║
# ║ Eén app-image (Express API + gebouwde Vite-frontend) + postgres. ║
# ║ ║
# ║ Stages: ║
# ║ Test backend-tests (Dockerfile target 'test') ║
# ║ Build productie-image bouwen + smoketest op de echte stack ║
# ║ Publish multi-arch image (amd64 + arm64) naar de registry pushen ║
# ║ ║
# ║ Vereist in Azure DevOps: ║
# ║ - Service connection (type: Docker Registry) naar de registry; vul ║
# ║ de naam in bij variabele dockerRegistryServiceConnection ║
# ║ - Agent met Docker en buildx (ubuntu-latest heeft dit) ║
# ╚══════════════════════════════════════════════════════════════════════════╝
trigger:
branches:
include:
- main
- master
- feature/*
tags:
include:
- "*"
pr:
branches:
include:
- main
- master
variables:
# ── Aanpassen per omgeving ──────────────────────────────────────────────
dockerRegistryServiceConnection: "vibefinance-registry"
registryHost: "vibehoogie.duckdns.org/vibe"
imageName: "vibefinance-app"
# ── Afgeleid ────────────────────────────────────────────────────────────
imageRepo: "$(registryHost)/$(imageName)"
pool:
vmImage: ubuntu-latest
stages:
# ── Stage 1: tests ──────────────────────────────────────────────────────
- stage: Test
displayName: Tests
jobs:
- job: BackendTests
displayName: Backend-tests (node --test)
steps:
- checkout: self
fetchDepth: 1
- script: |
set -euo pipefail
docker build --target test -t vibefinance/app:test .
docker run --rm vibefinance/app:test
displayName: npm test (in Docker, zonder database)
# ── Stage 2: image bouwen en smoketesten ────────────────────────────────
- stage: Build
displayName: Build en smoketest
dependsOn: Test
jobs:
- job: BuildImage
displayName: Productie-image + smoketest
steps:
- checkout: self
fetchDepth: 1
- script: |
set -euo pipefail
APP_VERSION=$(node -p "require('./package.json').version")
echo "##vso[task.setvariable variable=appVersion]$APP_VERSION"
echo "Versie: $APP_VERSION"
displayName: Versienummer uit package.json
- script: |
set -euo pipefail
docker build --target production \
--build-arg APP_VERSION=$(appVersion) \
--build-arg VITE_APP_VERSION=$(appVersion) \
--build-arg VITE_API_URL=/api \
-t vibefinance/app:ci .
displayName: Productie-image bouwen (incl. Vite-build)
- script: |
set -euo pipefail
docker network create vibefinance-ci
docker run -d --name ci-postgres --network vibefinance-ci \
-e POSTGRES_DB=vibefinance \
-e POSTGRES_USER=vibefinance \
-e POSTGRES_PASSWORD=cipassword \
postgres:18-alpine
for i in $(seq 1 30); do
if docker exec ci-postgres pg_isready -U vibefinance >/dev/null 2>&1; then break; fi
sleep 2
done
docker run -d --name ci-app --network vibefinance-ci \
-e NODE_ENV=production \
-e JWT_SECRET=ci-secret \
-e UPDATE_CHECK_URL= \
-e DATABASE_URL=postgresql://vibefinance:cipassword@ci-postgres:5432/vibefinance \
-p 3001:3001 \
vibefinance/app:ci
gezond=0
for i in $(seq 1 30); do
if curl -fsS http://localhost:3001/api/health | grep -q ok; then gezond=1; break; fi
sleep 2
done
if [ "$gezond" -ne 1 ]; then
echo "App werd niet gezond. Logs:"
docker logs ci-app
exit 1
fi
echo "/api/health : ok"
curl -fsS http://localhost:3001/ | grep -q root
echo "/ : index.html"
curl -fsS http://localhost:3001/voortgang | grep -q root
echo "/voortgang : index.html (SPA-fallback)"
code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3001/api/bestaat-niet)
test "$code" = "404"
echo "/api/... : 404 JSON"
displayName: Smoketest op app + postgres
- script: |
docker logs ci-app || true
docker rm -f ci-app ci-postgres || true
docker network rm vibefinance-ci || true
displayName: Opruimen
condition: always()
# ── Stage 3: publiceren ─────────────────────────────────────────────────
# Alleen vanaf main/master of een tag; feature-branches en PRs stoppen na Build.
- stage: Publish
displayName: Publiceren naar registry
dependsOn: Build
condition: |
and(succeeded(),
or(eq(variables['Build.SourceBranch'], 'refs/heads/main'),
eq(variables['Build.SourceBranch'], 'refs/heads/master'),
startsWith(variables['Build.SourceBranch'], 'refs/tags/')))
jobs:
- job: PushImage
displayName: Multi-arch image pushen
steps:
- checkout: self
fetchDepth: 1
- script: |
set -euo pipefail
APP_VERSION=$(node -p "require('./package.json').version")
echo "##vso[task.setvariable variable=appVersion]$APP_VERSION"
displayName: Versienummer bepalen
- task: Docker@2
displayName: Inloggen op de registry
inputs:
command: login
containerRegistry: $(dockerRegistryServiceConnection)
- script: |
set -euo pipefail
docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx create --name vibefinance --use
docker buildx inspect --bootstrap
displayName: buildx voor arm64 (Raspberry Pi) klaarzetten
- script: |
set -euo pipefail
docker buildx build \
--platform linux/amd64,linux/arm64 \
--target production \
--build-arg APP_VERSION=$(appVersion) \
--build-arg VITE_APP_VERSION=$(appVersion) \
--build-arg VITE_API_URL=/api \
-t $(imageRepo):$(appVersion) \
-t $(imageRepo):latest \
--push .
displayName: Bouwen en pushen (amd64 + arm64)
- script: |
echo "Gepubliceerd:"
echo " $(imageRepo):$(appVersion)"
echo " $(imageRepo):latest"
displayName: Samenvatting
-41
View File
@@ -1,41 +0,0 @@
# ╔══════════════════════════════════════════════╗
# ║ Backend Dockerfile – multi-stage ║
# ╚══════════════════════════════════════════════╝
# ── Stage 1: deps (incl. native build tools) ─────
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install && npm cache clean --force
# ── Stage 2: development (hot reload via --watch) ─
FROM deps AS development
COPY . .
EXPOSE 3001
CMD ["node", "--watch", "src/server.js"]
# ── Stage 3: productie-deps (apart gebouwd) ───────
FROM deps AS prod-deps
RUN npm prune --omit=dev
# ── Stage 4: production (minimale image) ──────────
FROM node:22-alpine AS production
LABEL maintainer="VibeFinance"
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY package.json ./
COPY src/ ./src/
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3001/api/health || exit 1
CMD ["node", "src/server.js"]
+1
View File
@@ -5,6 +5,7 @@
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"test": "node --test",
"lint": "eslint src --ext .js" "lint": "eslint src --ext .js"
}, },
"dependencies": { "dependencies": {
+10
View File
@@ -1,4 +1,8 @@
import "dotenv/config"; import "dotenv/config";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
export const config = { export const config = {
NODE_ENV: process.env.NODE_ENV || "development", NODE_ENV: process.env.NODE_ENV || "development",
@@ -9,6 +13,12 @@ export const config = {
DATABASE_URL: process.env.DATABASE_URL, DATABASE_URL: process.env.DATABASE_URL,
REDIS_URL: process.env.REDIS_URL, REDIS_URL: process.env.REDIS_URL,
// Map met de gebouwde frontend (Vite dist). In het productie-image staat die
// op /app/public; bestaat de map niet, dan serveert de app alleen de API.
STATIC_DIR: process.env.STATIC_DIR
? resolve(process.env.STATIC_DIR)
: join(__dirname, "..", "..", "public"),
// Update-notificaties: welke release-API wordt gepolld (Gitea). // Update-notificaties: welke release-API wordt gepolld (Gitea).
// Leeg laten schakelt de melding in de app uit. // Leeg laten schakelt de melding in de app uit.
UPDATE_CHECK_URL: process.env.UPDATE_CHECK_URL UPDATE_CHECK_URL: process.env.UPDATE_CHECK_URL
+28 -21
View File
@@ -7,27 +7,34 @@ export const db = new Pool({
connectionString: config.DATABASE_URL, connectionString: config.DATABASE_URL,
}); });
await db.query(` /**
CREATE TABLE IF NOT EXISTS users ( * Zet het schema klaar. Wordt bij het starten van de server aangeroepen —
id TEXT PRIMARY KEY, * niet bij het importeren, zodat de app ook zonder database te importeren is
naam TEXT NOT NULL, * (tests, health checks tijdens de build).
email TEXT UNIQUE NOT NULL, */
wachtwoord TEXT NOT NULL, export async function initDb() {
rol TEXT NOT NULL DEFAULT 'Viewer', await db.query(`
actief BOOLEAN NOT NULL DEFAULT true, CREATE TABLE IF NOT EXISTS users (
aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW() id TEXT PRIMARY KEY,
); naam TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
wachtwoord TEXT NOT NULL,
rol TEXT NOT NULL DEFAULT 'Viewer',
actief BOOLEAN NOT NULL DEFAULT true,
aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_data ( CREATE TABLE IF NOT EXISTS user_data (
user_id TEXT PRIMARY KEY, user_id TEXT PRIMARY KEY,
data TEXT NOT NULL data TEXT NOT NULL
); );
`); `);
await db.query(` await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW(); ALTER TABLE users ADD COLUMN IF NOT EXISTS aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW();
`); `);
await db.query(` await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT; ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;
`); `);
}
+61 -16
View File
@@ -3,9 +3,12 @@ import cors from "cors";
import helmet from "helmet"; import helmet from "helmet";
import rateLimit from "express-rate-limit"; import rateLimit from "express-rate-limit";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import pino from "pino";
import pinoHttp from "pino-http"; import pinoHttp from "pino-http";
import { existsSync } from "fs";
import { join } from "path";
import { config } from "./config/index.js"; import { config } from "./config/index.js";
import { logger } from "./logger.js";
import { initDb } from "./db.js";
import authRouter from "./routes/auth.js"; import authRouter from "./routes/auth.js";
import dataRouter from "./routes/data.js"; import dataRouter from "./routes/data.js";
import usersRouter from "./routes/users.js"; import usersRouter from "./routes/users.js";
@@ -13,23 +16,36 @@ import healthRouter from "./routes/health.js";
import updateRouter from "./routes/update.js"; import updateRouter from "./routes/update.js";
import { errorHandler } from "./middleware/errorHandler.js"; import { errorHandler } from "./middleware/errorHandler.js";
export const logger = pino({ export { logger };
level: config.NODE_ENV === "production" ? "info" : "debug",
...(config.NODE_ENV !== "production" && {
transport: { target: "pino-pretty", options: { colorize: true } },
}),
});
const app = express(); const app = express();
// ── Security ────────────────────────────────────────────────────────────────── // ── Security ──────────────────────────────────────────────────────────────────
app.use(helmet()); // De app serveert nu ook de frontend, dus het CSP-beleid moet passen bij wat
// die pagina laadt: Google Fonts, inline <style> van React en base64-avatars.
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
// Uit: intern draait de app op http achter een reverse proxy.
upgradeInsecureRequests: null,
},
},
}));
app.use(cors({ origin: config.CORS_ORIGIN, credentials: true })); app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
app.set("trust proxy", 1); app.set("trust proxy", 1);
// ── Rate limiting ───────────────────────────────────────────────────────────── // ── Rate limiting (alleen op de API — niet op statische bestanden) ────────────
app.use("/api/auth", rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false })); app.use("/api/auth", rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 200, standardHeaders: true, legacyHeaders: false })); app.use("/api", rateLimit({ windowMs: 15 * 60 * 1000, max: 200, standardHeaders: true, legacyHeaders: false }));
// ── Parsing ─────────────────────────────────────────────────────────────────── // ── Parsing ───────────────────────────────────────────────────────────────────
app.use(express.json({ limit: "2mb" })); app.use(express.json({ limit: "2mb" }));
@@ -40,23 +56,52 @@ if (config.NODE_ENV !== "test") {
app.use(pinoHttp({ logger })); app.use(pinoHttp({ logger }));
} }
// ── Routes ──────────────────────────────────────────────────────────────────── // ── API-routes ────────────────────────────────────────────────────────────────
app.use("/api/health", healthRouter); app.use("/api/health", healthRouter);
app.use("/api/update-check", updateRouter); app.use("/api/update-check", updateRouter);
app.use("/api/auth", authRouter); app.use("/api/auth", authRouter);
app.use("/api/data", dataRouter); app.use("/api/data", dataRouter);
app.use("/api/users", usersRouter); app.use("/api/users", usersRouter);
// ── 404 voor overige /api/* ─────────────────────────────────────────────────── // ── 404 voor overige /api/* ───────────────────────────────────────────────────
app.use("/api/*", (_req, res) => res.status(404).json({ error: "Endpoint niet gevonden." })); app.use("/api/*", (_req, res) => res.status(404).json({ error: "Endpoint niet gevonden." }));
// ── Frontend (Vite build) ─────────────────────────────────────────────────────
const indexHtml = join(config.STATIC_DIR, "index.html");
export const servesFrontend = existsSync(indexHtml);
if (servesFrontend) {
// Assets hebben een hash in de naam en mogen lang gecached worden;
// index.html juist nooit, anders blijft een oude build hangen.
app.use(express.static(config.STATIC_DIR, {
index: false,
setHeaders(res, path) {
res.setHeader("Cache-Control", path.endsWith(".html")
? "no-store, no-cache, must-revalidate"
: "public, max-age=31536000, immutable");
},
}));
// SPA-fallback: alle overige GET-routes naar index.html.
app.get("*", (_req, res) => {
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
res.sendFile(indexHtml);
});
} else {
logger.warn({ dir: config.STATIC_DIR }, "Geen frontend-build gevonden — alleen de API is beschikbaar");
}
// ── Error handler ───────────────────────────────────────────────────────────── // ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler); app.use(errorHandler);
// ── Start ───────────────────────────────────────────────────────────────────── // ── Start ─────────────────────────────────────────────────────────────────────
if (config.NODE_ENV !== "test") { if (config.NODE_ENV !== "test") {
await initDb();
app.listen(config.PORT, () => { app.listen(config.PORT, () => {
logger.info(`VibeFinance API gestart op http://localhost:${config.PORT} [${config.NODE_ENV}]`); logger.info(
`VibeFinance gestart op http://localhost:${config.PORT} [${config.NODE_ENV}]` +
(servesFrontend ? " — API + frontend" : " — alleen API")
);
}); });
} }
+117
View File
@@ -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);
});
});
+51 -64
View File
@@ -1,19 +1,20 @@
# VibeFinance dev script (PowerShell) # VibeFinance dev script (PowerShell)
# Lokale development cyclus: bouwen, starten, stoppen en optioneel pushen # Lokale development cyclus voor de 2-container opzet: bouwen, starten,
# naar de dev-registry. Gebruikt altijd de -dev images en tags. # stoppen en optioneel pushen naar de dev-registry.
#
# app API (node --watch) + Vite dev-server met hot reload
# postgres database
# #
# Gebruik: # Gebruik:
# .\dev.ps1 # build + herstart containers # .\dev.ps1 # build + herstart containers
# .\dev.ps1 build # alleen lokaal bouwen # .\dev.ps1 build # alleen lokaal bouwen
# .\dev.ps1 build -Target backend # alleen backend bouwen
# .\dev.ps1 build -Target frontend # alleen frontend bouwen
# .\dev.ps1 up # build + herstart containers # .\dev.ps1 up # build + herstart containers
# .\dev.ps1 up -Target backend # alleen backend bouwen + herstart # .\dev.ps1 up -Target app # alleen de app herbouwen + herstarten
# .\dev.ps1 up -Target frontend # alleen frontend bouwen + herstart # .\dev.ps1 up -Target postgres # alleen de database herstarten
# .\dev.ps1 down # containers stoppen en volumes verwijderen # .\dev.ps1 down # containers stoppen en volumes verwijderen
# .\dev.ps1 logs # logs van de app volgen
# .\dev.ps1 test # testsuite draaien (zie ook test.ps1)
# .\dev.ps1 push # multi-platform build + push naar registry # .\dev.ps1 push # multi-platform build + push naar registry
# .\dev.ps1 push -Target backend # alleen backend multi-platform pushen
# .\dev.ps1 push -Target frontend # alleen frontend multi-platform pushen
# #
# Vereisten: # Vereisten:
# - Docker Desktop actief # - Docker Desktop actief
@@ -22,11 +23,11 @@
# - root package.json bevat het versienummer (bijv. "1.0.0-dev") # - root package.json bevat het versienummer (bijv. "1.0.0-dev")
param( param(
[ValidateSet("build","up","down","push")] [ValidateSet("build","up","down","logs","test","push")]
[string]$Action = "up", [string]$Action = "up",
[ValidateSet("both","frontend","backend")] [ValidateSet("all","app","postgres")]
[string]$Target = "both" [string]$Target = "all"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -34,11 +35,10 @@ $ErrorActionPreference = "Stop"
# ============================================================================= # =============================================================================
# PROJECTCONFIGURATIE - pas alleen dit blok aan voor een nieuw project # PROJECTCONFIGURATIE - pas alleen dit blok aan voor een nieuw project
# ============================================================================= # =============================================================================
$appName = "VibeFinance" $appName = "VibeFinance"
$registry = "vibehoogie.duckdns.org/vibe" $registry = "vibehoogie.duckdns.org/vibe"
$backendImage = "vibefinance-backend" $appImage = "vibefinance-app"
$frontendImage = "vibefinance-frontend" $apiUrl = "/api"
$apiUrl = "/api"
# ============================================================================= # =============================================================================
# Versie uit root package.json # Versie uit root package.json
@@ -67,18 +67,17 @@ if ($frontendPkg.version -ne $appVersion) {
Write-Host "Versie gesynchroniseerd naar frontend: $appVersion" -ForegroundColor Green Write-Host "Versie gesynchroniseerd naar frontend: $appVersion" -ForegroundColor Green
} }
# Registry tags # Registry tags (één image voor de hele app)
$backendBase = "$registry/$backendImage" $appBase = "$registry/$appImage"
$frontendBase = "$registry/$frontendImage" $appLatestDev = "$appBase`:latest-dev"
$appVersionDev = "$appBase`:$appVersion"
$backendLatestDev = "$backendBase`:latest-dev"
$frontendLatestDev = "$frontendBase`:latest-dev"
$backendVersionDev = "$backendBase`:$appVersion"
$frontendVersionDev= "$frontendBase`:$appVersion"
# Pad naar compose file # Pad naar compose file
$devCompose = Join-Path $PSScriptRoot "docker-compose.dev.yml" $devCompose = Join-Path $PSScriptRoot "docker-compose.dev.yml"
# Welke services de actie raakt
$services = @(if ($Target -ne "all") { $Target })
Write-Host "" Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host " $appName Dev" -ForegroundColor Cyan Write-Host " $appName Dev" -ForegroundColor Cyan
@@ -90,38 +89,20 @@ Write-Host "========================================" -ForegroundColor Cyan
Write-Host "" Write-Host ""
# Functies # Functies
function Invoke-BackendPush { function Invoke-AppPush {
Write-Host "Backend bouwen en pushen..." -ForegroundColor White Write-Host "App bouwen en pushen (amd64 + arm64)..." -ForegroundColor White
docker buildx build ` docker buildx build `
--platform linux/amd64,linux/arm64 ` --platform linux/amd64,linux/arm64 `
--target production `
--push ` --push `
-t $backendLatestDev ` -t $appLatestDev `
-t $backendVersionDev ` -t $appVersionDev `
--build-arg APP_VERSION=$appVersion `
(Join-Path $PSScriptRoot "backend")
if ($LASTEXITCODE -ne 0) { throw "Backend build mislukt" }
Write-Host "Gepusht: $backendVersionDev" -ForegroundColor Green
}
function Invoke-FrontendPush {
Write-Host "Frontend bouwen en pushen..." -ForegroundColor White
docker buildx build `
--platform linux/amd64,linux/arm64 `
--push `
-t $frontendLatestDev `
-t $frontendVersionDev `
--build-arg APP_VERSION=$appVersion ` --build-arg APP_VERSION=$appVersion `
--build-arg VITE_API_URL=$apiUrl ` --build-arg VITE_API_URL=$apiUrl `
--build-arg VITE_REPO_URL=$repoUrl ` --build-arg VITE_APP_VERSION=$appVersion `
(Join-Path $PSScriptRoot "frontend") $PSScriptRoot
if ($LASTEXITCODE -ne 0) { throw "Frontend build mislukt" } if ($LASTEXITCODE -ne 0) { throw "Build mislukt" }
Write-Host "Gepusht: $frontendVersionDev" -ForegroundColor Green Write-Host "Gepusht: $appVersionDev" -ForegroundColor Green
}
function Invoke-Push {
Write-Host "-- Multi-platform bouwen en pushen -------" -ForegroundColor Cyan
if ($Target -eq "both" -or $Target -eq "backend") { Invoke-BackendPush }
if ($Target -eq "both" -or $Target -eq "frontend") { Invoke-FrontendPush }
} }
# Acties # Acties
@@ -129,7 +110,6 @@ switch ($Action) {
"build" { "build" {
Write-Host "-- Lokaal bouwen -------------------------" -ForegroundColor Cyan Write-Host "-- Lokaal bouwen -------------------------" -ForegroundColor Cyan
$services = @(if ($Target -ne "both") { $Target })
docker compose --progress plain -f $devCompose build @services docker compose --progress plain -f $devCompose build @services
if ($LASTEXITCODE -ne 0) { throw "Build mislukt" } if ($LASTEXITCODE -ne 0) { throw "Build mislukt" }
Write-Host "" Write-Host ""
@@ -137,8 +117,9 @@ switch ($Action) {
} }
"up" { "up" {
if ($Target -eq "both") { if ($Target -eq "all") {
docker compose -f $devCompose down -v # Let op: geen -v hier, dat zou de dev-database wissen (zie actie down)
docker compose -f $devCompose down
docker compose --progress plain -f $devCompose up --build -d docker compose --progress plain -f $devCompose up --build -d
} else { } else {
docker compose -f $devCompose stop $Target docker compose -f $devCompose stop $Target
@@ -147,6 +128,8 @@ switch ($Action) {
if ($LASTEXITCODE -ne 0) { throw "Opstarten mislukt" } if ($LASTEXITCODE -ne 0) { throw "Opstarten mislukt" }
Write-Host "" Write-Host ""
Write-Host "$appName gestart - $appVersion [$Target]" -ForegroundColor Green Write-Host "$appName gestart - $appVersion [$Target]" -ForegroundColor Green
Write-Host " App : http://localhost/" -ForegroundColor White
Write-Host " API : http://localhost:3001/api/health" -ForegroundColor White
} }
"down" { "down" {
@@ -155,19 +138,23 @@ switch ($Action) {
Write-Host "$appName gestopt" -ForegroundColor Yellow Write-Host "$appName gestopt" -ForegroundColor Yellow
} }
"logs" {
$svc = if ($Target -eq "all") { "app" } else { $Target }
docker compose -f $devCompose logs -f $svc
}
"test" {
& (Join-Path $PSScriptRoot "test.ps1")
if ($LASTEXITCODE -ne 0) { throw "Tests mislukt" }
}
"push" { "push" {
Invoke-Push Invoke-AppPush
Write-Host "" Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Dev images gepusht: $appVersion [$Target]" -ForegroundColor Green Write-Host " Dev image gepusht: $appVersion" -ForegroundColor Green
if ($Target -eq "both" -or $Target -eq "backend") { Write-Host " $appVersionDev" -ForegroundColor White
Write-Host " $backendVersionDev" -ForegroundColor White Write-Host " $appLatestDev" -ForegroundColor White
Write-Host " $backendLatestDev" -ForegroundColor White
}
if ($Target -eq "both" -or $Target -eq "frontend") {
Write-Host " $frontendVersionDev" -ForegroundColor White
Write-Host " $frontendLatestDev" -ForegroundColor White
}
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
} }
} }
+10 -11
View File
@@ -1,25 +1,24 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ── VibeFinance dev wrapper (macOS/Linux) ──────────────────────────────── # ── VibeFinance dev wrapper (macOS/Linux) ────────────────────────────────
# Roept dev.ps1 aan via PowerShell Core (pwsh). # Roept dev.ps1 aan via PowerShell Core (pwsh). 2-container opzet:
# app (API + Vite dev-server) en postgres.
# #
# Gebruik: # Gebruik:
# ./dev.sh # build + herstart containers # ./dev.sh # build + herstart containers
# ./dev.sh build # alleen lokaal bouwen # ./dev.sh build # alleen lokaal bouwen
# ./dev.sh build -Target backend # alleen backend bouwen # ./dev.sh up # build + herstart containers
# ./dev.sh build -Target frontend # alleen frontend bouwen # ./dev.sh up -Target app # alleen de app herbouwen + herstarten
# ./dev.sh up # build lokaal + herstart containers # ./dev.sh up -Target postgres # alleen de database herstarten
# ./dev.sh up -Target backend # alleen backend bouwen + herstart # ./dev.sh down # containers stoppen en volumes wissen
# ./dev.sh up -Target frontend # alleen frontend bouwen + herstart # ./dev.sh logs # logs van de app volgen
# ./dev.sh down # containers stoppen # ./dev.sh test # testsuite draaien
# ./dev.sh push # multi-platform build + push # ./dev.sh push # multi-platform build + push
# ./dev.sh push -Target backend # alleen backend pushen
# ./dev.sh push -Target frontend # alleen frontend pushen
# #
# Vereisten: # Vereisten:
# - pwsh (PowerShell Core) geïnstalleerd: brew install powershell # - pwsh (PowerShell Core) geïnstalleerd: brew install powershell
# - Docker Desktop actief # - Docker actief
# - Voor 'push': docker buildx builder actief: docker buildx create --use # - Voor 'push': docker buildx builder actief: docker buildx create --use
# - Voor 'push': ingelogd op registry via: docker login 10.0.3.108:3000 # - Voor 'push': ingelogd op de registry: docker login <registry>
set -euo pipefail set -euo pipefail
+20 -35
View File
@@ -1,8 +1,14 @@
## ##
## Standalone dev stack — lokaal bouwen en testen ## VibeFinance dev-stack — 2 containers, zelfde structuur als productie
##
## app API (node --watch) + Vite dev-server met hot reload
## postgres database
## ##
## docker compose -f docker-compose.dev.yml up --build ## docker compose -f docker-compose.dev.yml up --build
## ##
## De browser praat met Vite op poort 80; Vite proxyt /api naar de API in
## dezelfde container (localhost:3001). De API is ook direct bereikbaar op 3001.
##
services: services:
@@ -18,72 +24,51 @@ services:
networks: networks:
- dev - dev
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U vibefinance"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vibefinance}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
frontend: app:
build: build:
context: ./frontend context: .
dockerfile: Dockerfile dockerfile: Dockerfile
target: development target: development
args: args:
VITE_APP_VERSION: ${VITE_APP_VERSION:-dev} VITE_APP_VERSION: ${VITE_APP_VERSION:-dev}
image: vibefinance/frontend:${VERSION:-latest-dev} image: vibefinance/app:${VERSION:-latest-dev}
container_name: vibefinance-frontend-dev container_name: vibefinance-app-dev
ports: ports:
- "80:5173" - "80:5173" # Vite dev-server (de app in de browser)
volumes: - "3001:3001" # API, handig voor curl/Postman
- ./frontend:/app
- /app/node_modules
networks:
- dev
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:5173/"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: development
image: vibefinance/backend:${VERSION:-latest-dev}
container_name: vibefinance-backend-dev
expose:
- "3001"
volumes: volumes:
- ./backend:/app - ./backend:/app
- /app/node_modules - /app/node_modules
- ./frontend:/app/frontend
- /app/frontend/node_modules
environment: environment:
NODE_ENV: development NODE_ENV: development
PORT: 3001 PORT: 3001
JWT_SECRET: dev-secret-change-in-production JWT_SECRET: dev-secret-change-in-production
JWT_EXPIRES: 7d JWT_EXPIRES: 7d
CORS_ORIGIN: http://localhost CORS_ORIGIN: http://localhost
UPDATE_CHECK_URL: ${UPDATE_CHECK_URL:-https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest}
DATABASE_URL: postgresql://${POSTGRES_USER:-vibefinance}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-vibefinance} DATABASE_URL: postgresql://${POSTGRES_USER:-vibefinance}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-vibefinance}
UPDATE_CHECK_URL: ${UPDATE_CHECK_URL:-https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest}
networks: networks:
- dev - dev
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"] # 127.0.0.1 i.p.v. localhost: Vite luistert alleen op IPv4
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/api/health && wget -qO- http://127.0.0.1:5173/ > /dev/null || exit 1"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 15s start_period: 25s
volumes: volumes:
postgres_dev_data: postgres_dev_data:
networks: networks:
dev: dev:
driver: bridge
+17 -21
View File
@@ -1,9 +1,12 @@
# VibeFinance — docker-compose voor deployment via pre-built images # VibeFinance — productie/self-hosting: 2 containers
#
# app Express API + gebouwde frontend (poort 3001 in de container)
# postgres database met persistent volume
# #
# Zie README.md § Quick Start voor volledige instructies. # Zie README.md § Quick Start voor volledige instructies.
# #
# Stappen: # Stappen:
# 1. cp .env.example .env (vul JWT_SECRET, CORS_ORIGIN en DB_PASSWORD in) # 1. cp .env.example .env (vul JWT_SECRET, POSTGRES_PASSWORD in)
# 2. docker compose pull # 2. docker compose pull
# 3. docker compose up -d # 3. docker compose up -d
@@ -20,46 +23,39 @@ services:
volumes: volumes:
- vibefinance_pgdata:/var/lib/postgresql - vibefinance_pgdata:/var/lib/postgresql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U vibefinance"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vibefinance}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks: networks:
- vibefinance_net - vibefinance_net
backend: app:
image: vibehoogie.duckdns.org/vibe/vibefinance-backend:latest image: vibehoogie.duckdns.org/vibe/vibefinance-app:latest
container_name: vibefinance_backend container_name: vibefinance_app
restart: unless-stopped restart: unless-stopped
# Variabelen uit .env (JWT_SECRET, POSTGRES_PASSWORD, ...)
env_file: .env env_file: .env
environment: environment:
NODE_ENV: production
PORT: 3001
DATABASE_URL: postgresql://${POSTGRES_USER:-vibefinance}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-vibefinance} DATABASE_URL: postgresql://${POSTGRES_USER:-vibefinance}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-vibefinance}
# Bron voor de update-melding in de app (leeg = melding uit) # Bron voor de update-melding in de app (leeg = melding uit)
UPDATE_CHECK_URL: ${UPDATE_CHECK_URL:-https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest} UPDATE_CHECK_URL: ${UPDATE_CHECK_URL:-https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest}
ports:
- "${APP_PORT:-3400}:3001"
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/health | grep -q status || exit 1"] test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/health | grep -q status || exit 1"]
interval: 10s interval: 30s
timeout: 5s timeout: 5s
retries: 10 retries: 5
start_period: 30s start_period: 20s
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
networks: networks:
- vibefinance_net - vibefinance_net
frontend:
image: vibehoogie.duckdns.org/vibe/vibefinance-frontend:latest
container_name: vibefinance_frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-3400}:80"
depends_on:
backend:
condition: service_healthy
networks:
- vibefinance_net
volumes: volumes:
vibefinance_pgdata: vibefinance_pgdata:
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Start de API en de Vite dev-server samen in de dev-container.
# De browser praat met Vite (5173); Vite proxyt /api naar de API op 3001.
set -e
cd /app
node --watch src/server.js &
API_PID=$!
cd /app/frontend
node_modules/.bin/vite --host 0.0.0.0 --port 5173 &
WEB_PID=$!
afsluiten() {
kill "$API_PID" "$WEB_PID" 2>/dev/null
exit 0
}
trap afsluiten TERM INT
# Valt één van de twee processen weg, dan stopt de container (compose herstart).
wait -n
kill "$API_PID" "$WEB_PID" 2>/dev/null
exit 1
-42
View File
@@ -1,42 +0,0 @@
# ╔══════════════════════════════════════════════╗
# ║ Frontend Dockerfile – multi-stage ║
# ╚══════════════════════════════════════════════╝
# ── Stage 1: deps ────────────────────────────────
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install && npm cache clean --force
# ── Stage 2: development (hot reload) ────────────
FROM deps AS development
ARG VITE_APP_VERSION=dev
ENV VITE_APP_VERSION=$VITE_APP_VERSION
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
# ── Stage 3: build ───────────────────────────────
FROM deps AS builder
ARG VITE_API_URL=/api
ARG VITE_APP_VERSION=dev
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_APP_VERSION=$VITE_APP_VERSION
COPY . .
RUN npm run build
# ── Stage 4: production (nginx static) ───────────
FROM nginx:1.27-alpine AS production
LABEL maintainer="VibeFinance"
# Kopieer de Vite build output
COPY --from=builder /app/dist /usr/share/nginx/html
# Nginx config voor SPA (history-mode fallback)
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
-32
View File
@@ -1,32 +0,0 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Gzip voor statische assets
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# Cache-control: assets met hash in naam → lang; index.html → nooit
location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|ico|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Proxy API-calls naar de backend
location /api/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback – stuur alle routes naar index.html
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vibefinance-frontend", "name": "vibefinance-frontend",
"version": "0.1.5-dev", "version": "0.1.6-dev",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -2
View File
@@ -11,10 +11,10 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173, port: 5173,
// In dev: proxy /api naar de backend zodat je geen CORS-issues hebt // In dev: proxy /api naar de API in dezelfde container (zie docker/dev-entrypoint.sh)
proxy: { proxy: {
"/api": { "/api": {
target: "http://backend:3001", target: "http://localhost:3001",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vibefinance", "name": "vibefinance",
"version": "0.1.5-dev", "version": "0.1.6-dev",
"description": "VibeFinance — persoonlijk vermogensbeheer", "description": "VibeFinance — persoonlijk vermogensbeheer",
"private": true, "private": true,
"repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance", "repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance",
+72 -78
View File
@@ -1,24 +1,27 @@
# ── VibeFinance release script (PowerShell) ────────────────────────────── # ── VibeFinance release script (PowerShell) ──────────────────────────────
# Productie release: bouwt multi-platform, pusht naar registry, # Productie release voor de 2-container opzet: draait de tests, bouwt het
# maakt een Git-tag aan en pusht naar origin. # gecombineerde app-image multi-platform, pusht naar de registry, maakt een
# Git-tag en publiceert de release in Gitea.
# #
# Gebruik: # Gebruik:
# .\release.ps1 # bouw + push images + Git-tag # .\release.ps1 # test + bouw + push + Git-tag + Gitea-release
# .\release.ps1 -NoBuild # sla docker build over, alleen Git-tag # .\release.ps1 -NoBuild # sla docker build over, alleen tag + release
# .\release.ps1 -SkipTests # sla de testsuite over (niet aanbevolen)
# .\release.ps1 -DryRun # toon alle commando's zonder iets uit te voeren # .\release.ps1 -DryRun # toon alle commando's zonder iets uit te voeren
# .\release.ps1 -Force # sla conflictcontrole over, overschrijf bestaande tags # .\release.ps1 -Force # sla conflictcontrole over, overschrijf tags
# #
# Vereisten: # Vereisten:
# - Docker Desktop actief # - Docker Desktop actief
# - Ingelogd op de registry: docker login <registry> # - Ingelogd op de registry: docker login <registry>
# - docker buildx builder actief: docker buildx create --use # - docker buildx builder actief: docker buildx create --use
# - Git geïnstalleerd en working directory is de repo root # - Git geinstalleerd en working directory is de repo root
# - root package.json bevat een schoon versienummer ZONDER -dev suffix # - root package.json bevat een schoon versienummer ZONDER -dev suffix
# - $env:GITEA_TOKEN gezet (token met release-rechten) — nodig voor stap 4, # - GITEA_TOKEN in de omgeving (token met release-rechten) voor stap 5,
# de Gitea-release waar de update-melding in de app op afgaat # de Gitea-release waar de update-melding in de app op afgaat
param( param(
[switch]$NoBuild, [switch]$NoBuild,
[switch]$SkipTests,
[switch]$DryRun, [switch]$DryRun,
[switch]$Force [switch]$Force
) )
@@ -28,11 +31,11 @@ $ErrorActionPreference = "Stop"
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
# ── PROJECTCONFIGURATIE — pas alleen dit blok aan voor een nieuw project ── # ── PROJECTCONFIGURATIE — pas alleen dit blok aan voor een nieuw project ──
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
$appName = "VibeFinance" $appName = "VibeFinance"
$registry = "vibehoogie.duckdns.org/vibe" $registry = "vibehoogie.duckdns.org/vibe"
$backendImage = "vibefinance-backend" $appImage = "vibefinance-app"
$frontendImage = "vibefinance-frontend" $apiUrl = "/api"
$apiUrl = "/api" $platforms = "linux/amd64,linux/arm64" # arm64 = Raspberry Pi
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
# ── Versie uit root package.json ─────────────────────────────────────────── # ── Versie uit root package.json ───────────────────────────────────────────
@@ -60,14 +63,10 @@ if ($frontendPkg.version -ne $appVersion) {
$gitTag = "$appVersion" $gitTag = "$appVersion"
# ── Registry tags ────────────────────────────────────────────────────────── # ── Registry tags (één image voor frontend + backend) ─────────────────────
$backendBase = "$registry/$backendImage" $appBase = "$registry/$appImage"
$frontendBase = "$registry/$frontendImage" $appVersionTag = "$appBase`:$appVersion"
$appLatestTag = "$appBase`:latest"
$backendVersionTag = "$backendBase`:$appVersion"
$frontendVersionTag = "$frontendBase`:$appVersion"
$backendLatestTag = "$backendBase`:latest"
$frontendLatestTag = "$frontendBase`:latest"
# ── Helpers ──────────────────────────────────────────────────────────────── # ── Helpers ────────────────────────────────────────────────────────────────
function Invoke-Cmd { function Invoke-Cmd {
@@ -116,14 +115,16 @@ Write-Host "══════════════════════
Write-Host " $appName Release" -ForegroundColor Cyan Write-Host " $appName Release" -ForegroundColor Cyan
Write-Host " Versie : $appVersion" -ForegroundColor Green Write-Host " Versie : $appVersion" -ForegroundColor Green
Write-Host " Git-tag : $gitTag" -ForegroundColor Green Write-Host " Git-tag : $gitTag" -ForegroundColor Green
Write-Host " Registry: $registry" -ForegroundColor Green Write-Host " Image : $appVersionTag" -ForegroundColor Green
if ($DryRun) { Write-Host " Mode : DRY-RUN (geen wijzigingen)" -ForegroundColor Yellow } Write-Host " Platform: $platforms" -ForegroundColor Green
if ($NoBuild) { Write-Host " Mode : SKIP BUILD" -ForegroundColor Yellow } if ($DryRun) { Write-Host " Mode : DRY-RUN (geen wijzigingen)" -ForegroundColor Yellow }
if ($Force) { Write-Host " Mode : FORCE (conflictcontrole overgeslagen)" -ForegroundColor Red } if ($NoBuild) { Write-Host " Mode : SKIP BUILD" -ForegroundColor Yellow }
if ($SkipTests) { Write-Host " Mode : SKIP TESTS" -ForegroundColor Yellow }
if ($Force) { Write-Host " Mode : FORCE (conflictcontrole overgeslagen)" -ForegroundColor Red }
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
# ── Stap 1: Conflictcontrole ────────────────────────────────────────────── # ── Stap 1: Conflictcontrole ──────────────────────────────────────────────
Write-Step "[1/4] Conflictcontrole" Write-Step "[1/5] Conflictcontrole"
if (-not $DryRun -and -not $Force) { if (-not $DryRun -and -not $Force) {
$localTag = git tag --list $gitTag $localTag = git tag --list $gitTag
@@ -138,23 +139,13 @@ if (-not $DryRun -and -not $Force) {
exit 1 exit 1
} }
Write-Host "Controleer registry: $backendVersionTag" -ForegroundColor DarkGray Write-Host "Controleer registry: $appVersionTag" -ForegroundColor DarkGray
$ErrorActionPreference = "SilentlyContinue" $ErrorActionPreference = "SilentlyContinue"
docker manifest inspect $backendVersionTag 2>$null docker manifest inspect $appVersionTag 2>$null | Out-Null
$backendExists = $LASTEXITCODE -eq 0 $imageExists = $LASTEXITCODE -eq 0
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
if ($backendExists) { if ($imageExists) {
Write-Error "Registry-tag '$backendVersionTag' bestaat al. Verhoog het versienummer in package.json of gebruik -Force." Write-Error "Registry-tag '$appVersionTag' bestaat al. Verhoog het versienummer in package.json of gebruik -Force."
exit 1
}
Write-Host "Controleer registry: $frontendVersionTag" -ForegroundColor DarkGray
$ErrorActionPreference = "SilentlyContinue"
docker manifest inspect $frontendVersionTag 2>$null
$frontendExists = $LASTEXITCODE -eq 0
$ErrorActionPreference = "Stop"
if ($frontendExists) {
Write-Error "Registry-tag '$frontendVersionTag' bestaat al. Verhoog het versienummer in package.json of gebruik -Force."
exit 1 exit 1
} }
@@ -165,38 +156,44 @@ if (-not $DryRun -and -not $Force) {
Write-Host "[DRY-RUN] Conflictcontrole overgeslagen" -ForegroundColor Yellow Write-Host "[DRY-RUN] Conflictcontrole overgeslagen" -ForegroundColor Yellow
} }
# ── Stap 2: Bouwen en pushen ────────────────────────────────────────────── # ── Stap 2: Tests ─────────────────────────────────────────────────────────
if (-not $NoBuild) { if (-not $SkipTests) {
Write-Step "[2/4] Multi-platform bouwen en pushen" Write-Step "[2/5] Testsuite"
if ($DryRun) {
Write-Host "Backend bouwen en pushen..." -ForegroundColor White Write-Host "[DRY-RUN] test.ps1 -NoSmoke" -ForegroundColor Yellow
Invoke-Cmd @( } else {
"docker", "buildx", "build", & (Join-Path $PSScriptRoot "test.ps1") -NoSmoke
"--platform", "linux/amd64,linux/arm64", if ($LASTEXITCODE -ne 0) {
"--push", Write-Error "Tests mislukt — release afgebroken."
"-t", $backendVersionTag, exit 1
"-t", $backendLatestTag, }
"--build-arg", "APP_VERSION=$appVersion", }
(Join-Path $PSScriptRoot "backend")
)
Write-Host "Frontend bouwen en pushen..." -ForegroundColor White
Invoke-Cmd @(
"docker", "buildx", "build",
"--platform", "linux/amd64,linux/arm64",
"--push",
"-t", $frontendVersionTag,
"-t", $frontendLatestTag,
"--build-arg", "APP_VERSION=$appVersion",
"--build-arg", "VITE_API_URL=$apiUrl",
(Join-Path $PSScriptRoot "frontend")
)
} else { } else {
Write-Step "[2/4] Bouwen overgeslagen (-NoBuild)" Write-Step "[2/5] Tests overgeslagen (-SkipTests)"
} }
# ── Stap 3: Git-tag aanmaken en pushen ──────────────────────────────────── # ── Stap 3: Bouwen en pushen ──────────────────────────────────────────────
Write-Step "[3/4] Git-tag aanmaken en pushen" if (-not $NoBuild) {
Write-Step "[3/5] Multi-platform bouwen en pushen"
Invoke-Cmd @(
"docker", "buildx", "build",
"--platform", $platforms,
"--target", "production",
"--push",
"-t", $appVersionTag,
"-t", $appLatestTag,
"--build-arg", "APP_VERSION=$appVersion",
"--build-arg", "VITE_API_URL=$apiUrl",
"--build-arg", "VITE_APP_VERSION=$appVersion",
$PSScriptRoot
)
} else {
Write-Step "[3/5] Bouwen overgeslagen (-NoBuild)"
}
# ── Stap 4: Git-tag aanmaken en pushen ────────────────────────────────────
Write-Step "[4/5] Git-tag aanmaken en pushen"
if ($Force) { if ($Force) {
Write-Host "Bestaande lokale tag verwijderen (indien aanwezig)..." -ForegroundColor Yellow Write-Host "Bestaande lokale tag verwijderen (indien aanwezig)..." -ForegroundColor Yellow
@@ -215,8 +212,8 @@ Invoke-Cmd @("git", "tag", "-a", $gitTag, "-m", "Release $gitTag")
Write-Host "Git-tag pushen naar origin" -ForegroundColor White Write-Host "Git-tag pushen naar origin" -ForegroundColor White
Invoke-Cmd @("git", "push", "origin", "refs/tags/$gitTag") Invoke-Cmd @("git", "push", "origin", "refs/tags/$gitTag")
# ── Stap 4: Gitea-release aanmaken ──────────────────────────────────────── # ── Stap 5: Gitea-release aanmaken ────────────────────────────────────────
Write-Step "[4/4] Gitea-release aanmaken" Write-Step "[5/5] Gitea-release aanmaken"
# De update-melding in de app leest /releases/latest. Een kale Git-tag komt # De update-melding in de app leest /releases/latest. Een kale Git-tag komt
# daar niet in terug, dus er moet een echte release van gemaakt worden. # daar niet in terug, dus er moet een echte release van gemaakt worden.
@@ -232,7 +229,6 @@ if (-not $releaseApi) {
} elseif (-not $giteaToken) { } elseif (-not $giteaToken) {
Write-Host "Geen GITEA_TOKEN in de omgeving — release overgeslagen." -ForegroundColor Yellow Write-Host "Geen GITEA_TOKEN in de omgeving — release overgeslagen." -ForegroundColor Yellow
Write-Host " Zonder release ziet de app geen update-melding voor $appVersion." -ForegroundColor DarkGray Write-Host " Zonder release ziet de app geen update-melding voor $appVersion." -ForegroundColor DarkGray
Write-Host ' Token met release-rechten zetten: $env:GITEA_TOKEN = "<token>"' -ForegroundColor DarkGray
} else { } else {
$notes = Get-ChangelogSection $appVersion $notes = Get-ChangelogSection $appVersion
if (-not $notes) { $notes = "Release $appVersion" } if (-not $notes) { $notes = "Release $appVersion" }
@@ -260,7 +256,7 @@ if (-not $releaseApi) {
Write-Host "Release voor $gitTag bestaat al — overgeslagen." -ForegroundColor Yellow Write-Host "Release voor $gitTag bestaat al — overgeslagen." -ForegroundColor Yellow
} else { } else {
Write-Host "Release aanmaken mislukt ($code): $($_.Exception.Message)" -ForegroundColor Red Write-Host "Release aanmaken mislukt ($code): $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Tag en images staan al klaar; maak de release eventueel handmatig aan." -ForegroundColor DarkGray Write-Host "Tag en image staan al klaar; maak de release eventueel handmatig aan." -ForegroundColor DarkGray
} }
} }
} }
@@ -271,11 +267,9 @@ Write-Host ""
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " Gepubliceerd: $appVersion" -ForegroundColor Green Write-Host " Gepubliceerd: $appVersion" -ForegroundColor Green
Write-Host "" Write-Host ""
Write-Host " Registry images:" -ForegroundColor Green Write-Host " Registry image:" -ForegroundColor Green
Write-Host " $backendVersionTag" -ForegroundColor White Write-Host " $appVersionTag" -ForegroundColor White
Write-Host " $backendLatestTag" -ForegroundColor White Write-Host " $appLatestTag" -ForegroundColor White
Write-Host " $frontendVersionTag" -ForegroundColor White
Write-Host " $frontendLatestTag" -ForegroundColor White
Write-Host "" Write-Host ""
Write-Host " Git-tag: $gitTag -> origin" -ForegroundColor Green Write-Host " Git-tag: $gitTag -> origin" -ForegroundColor Green
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
+8 -7
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ── VibeFinance release wrapper (macOS/Linux) ──────────────────────────── # ── VibeFinance release wrapper (macOS/Linux) ────────────────────────────
# Roept release.ps1 aan via PowerShell Core (pwsh). # Roept release.ps1 aan via PowerShell Core (pwsh). Bouwt één app-image
# (API + frontend) voor amd64 en arm64.
# #
# Gebruik: # Gebruik:
# ./release.sh # bouw + push images + Git-tag # ./release.sh # test + bouw + push + Git-tag + Gitea-release
# ./release.sh -NoBuild # sla docker build over, alleen Git-tag # ./release.sh -NoBuild # sla docker build over, alleen tag + release
# ./release.sh -SkipTests # sla de testsuite over (niet aanbevolen)
# ./release.sh -DryRun # toon alle commando's zonder iets uit te voeren # ./release.sh -DryRun # toon alle commando's zonder iets uit te voeren
# ./release.sh -Force # sla conflictcontrole over, overschrijf bestaande tags # ./release.sh -Force # sla conflictcontrole over, overschrijf tags
# #
# Combinaties: # Combinaties:
# ./release.sh -Force -NoBuild # ./release.sh -Force -NoBuild
@@ -14,9 +16,9 @@
# #
# Vereisten: # Vereisten:
# - pwsh (PowerShell Core) geïnstalleerd: brew install powershell # - pwsh (PowerShell Core) geïnstalleerd: brew install powershell
# - Docker Desktop actief # - Docker actief
# - docker buildx builder actief: docker buildx create --use # - docker buildx builder actief: docker buildx create --use
# - Ingelogd op de registry: docker login vibetea.vldn.net # - Ingelogd op de registry: docker login vibehoogie.duckdns.org
# - Git geïnstalleerd # - Git geïnstalleerd
# - root package.json bevat versienummer ZONDER -dev suffix # - root package.json bevat versienummer ZONDER -dev suffix
# - GITEA_TOKEN in de omgeving (token met release-rechten) — nodig voor het # - GITEA_TOKEN in de omgeving (token met release-rechten) — nodig voor het
@@ -31,7 +33,6 @@ if ! command -v pwsh &>/dev/null; then
exit 1 exit 1
fi fi
# Geef alle argumenten door aan het .ps1 script
pwsh -NoProfile -ExecutionPolicy Bypass \ pwsh -NoProfile -ExecutionPolicy Bypass \
-File "$SCRIPT_DIR/release.ps1" \ -File "$SCRIPT_DIR/release.ps1" \
"$@" "$@"
+145
View File
@@ -0,0 +1,145 @@
# ── VibeFinance testscript (PowerShell) ──────────────────────────────────
# Draait de volledige testsuite voor de 2-container opzet. Alles gebeurt in
# Docker, dus je hebt geen Node op de host nodig.
#
# 1. Backend-tests (Dockerfile target 'test' → node --test, zonder database)
# 2. Productiebuild (Vite-build + app-image; faalt de frontend, faalt dit)
# 3. Smoketest (productie-image + postgres, /api/health en / opvragen)
#
# De smoketest publiceert geen hostpoorten en gebruikt een eigen netwerk, dus
# hij botst niet met een draaiende dev-stack.
#
# Gebruik:
# .\test.ps1 # alle stappen
# .\test.ps1 -Unit # alleen de backend-tests
# .\test.ps1 -NoSmoke # tests + build, zonder de stack te starten
param(
[switch]$Unit,
[switch]$NoSmoke
)
$ErrorActionPreference = "Stop"
$appVersion = (Get-Content (Join-Path $PSScriptRoot "package.json") -Raw | ConvertFrom-Json).version
$testImage = "vibefinance/app:test-$appVersion"
$prodImage = "vibefinance/app:build-$appVersion"
$smokeNet = "vibefinance-smoke-net"
$smokeDb = "vibefinance-smoke-db"
$smokeApp = "vibefinance-smoke-app"
function Write-Step {
param([string]$Label)
Write-Host ""
Write-Host "── $Label" -ForegroundColor Cyan
}
# ── 1. Backend-tests ──────────────────────────────────────────────────────
Write-Step "[1/3] Backend-tests"
docker build --target test -t $testImage $PSScriptRoot
if ($LASTEXITCODE -ne 0) { Write-Error "Testimage bouwen mislukt"; exit 1 }
docker run --rm $testImage
if ($LASTEXITCODE -ne 0) { Write-Error "Backend-tests mislukt"; exit 1 }
Write-Host "Backend-tests geslaagd" -ForegroundColor Green
if ($Unit) {
Write-Host ""
Write-Host "Alleen unit-tests gevraagd — klaar." -ForegroundColor Green
exit 0
}
# ── 2. Productiebuild (inclusief Vite-build) ──────────────────────────────
Write-Step "[2/3] Productie-image bouwen"
docker build --target production `
--build-arg APP_VERSION=$appVersion `
--build-arg VITE_APP_VERSION=$appVersion `
--build-arg VITE_API_URL=/api `
-t $prodImage $PSScriptRoot
if ($LASTEXITCODE -ne 0) { Write-Error "Productiebuild mislukt"; exit 1 }
Write-Host "Productie-image gebouwd: $prodImage" -ForegroundColor Green
if ($NoSmoke) {
Write-Host ""
Write-Host "Smoketest overgeslagen (-NoSmoke) — klaar." -ForegroundColor Green
exit 0
}
# ── 3. Smoketest op het productie-image + postgres ────────────────────────
Write-Step "[3/3] Smoketest (app + postgres)"
function Remove-SmokeStack {
docker rm -f $smokeApp $smokeDb 2>$null | Out-Null
docker network rm $smokeNet 2>$null | Out-Null
}
# Eventuele resten van een eerdere run opruimen
Remove-SmokeStack
try {
docker network create $smokeNet | Out-Null
docker run -d --name $smokeDb --network $smokeNet `
-e POSTGRES_DB=vibefinance `
-e POSTGRES_USER=vibefinance `
-e POSTGRES_PASSWORD=smokepassword `
postgres:18-alpine | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Database-container starten mislukt" }
Write-Host " Wachten op postgres..." -ForegroundColor DarkGray
$dbGereed = $false
foreach ($poging in 1..30) {
docker exec $smokeDb pg_isready -U vibefinance 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { $dbGereed = $true; break }
Start-Sleep -Seconds 2
}
if (-not $dbGereed) { throw "Postgres werd niet bereikbaar" }
docker run -d --name $smokeApp --network $smokeNet `
-e NODE_ENV=production `
-e JWT_SECRET=smoke-secret `
-e UPDATE_CHECK_URL= `
-e DATABASE_URL=postgresql://vibefinance:smokepassword@$smokeDb`:5432/vibefinance `
$prodImage | Out-Null
if ($LASTEXITCODE -ne 0) { throw "App-container starten mislukt" }
Write-Host " Wachten op de app..." -ForegroundColor DarkGray
$health = $null
foreach ($poging in 1..30) {
$health = (docker exec $smokeApp wget -qO- http://127.0.0.1:3001/api/health 2>$null) -join ""
if ($LASTEXITCODE -eq 0 -and $health -match '"status"') { break }
$health = $null
Start-Sleep -Seconds 2
}
if (-not $health) {
docker logs --tail 40 $smokeApp
throw "App werd niet gezond"
}
Write-Host " /api/health : ok" -ForegroundColor Green
# wget levert meerdere regels; samenvoegen tot één string, want -match op een
# array filtert de array in plaats van een test te doen.
$index = (docker exec $smokeApp wget -qO- http://127.0.0.1:3001/) -join "`n"
if ($LASTEXITCODE -ne 0 -or $index -notmatch 'id="root"') { throw "Root gaf geen index.html terug" }
Write-Host " / : index.html" -ForegroundColor Green
$spa = (docker exec $smokeApp wget -qO- http://127.0.0.1:3001/voortgang) -join "`n"
if ($LASTEXITCODE -ne 0 -or $spa -notmatch 'id="root"') { throw "SPA-fallback werkt niet" }
Write-Host " /voortgang : index.html (SPA-fallback)" -ForegroundColor Green
$api404 = (docker exec $smokeApp wget -qO- --server-response http://127.0.0.1:3001/api/bestaat-niet 2>&1) -join "`n"
if ($api404 -notmatch "404") { throw "Onbekende API-route gaf geen 404" }
Write-Host " /api/bestaat-niet: 404" -ForegroundColor Green
Write-Host ""
Write-Host "Smoketest geslaagd" -ForegroundColor Green
}
finally {
Remove-SmokeStack
}
Write-Host ""
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " Alle tests geslaagd - $appVersion" -ForegroundColor Green
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
Write-Host ""
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# ── VibeFinance test wrapper (macOS/Linux) ───────────────────────────────
# Roept test.ps1 aan via PowerShell Core (pwsh).
#
# Gebruik:
# ./test.sh # backend-tests + productiebuild + smoketest
# ./test.sh -Unit # alleen de backend-tests
# ./test.sh -NoSmoke # zonder de stack te starten
#
# Vereisten:
# - pwsh (PowerShell Core): brew install powershell
# - Docker actief
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if ! command -v pwsh &>/dev/null; then
echo "Fout: pwsh niet gevonden. Installeer via: brew install powershell" >&2
exit 1
fi
pwsh -NoProfile -ExecutionPolicy Bypass \
-File "$SCRIPT_DIR/test.ps1" \
"$@"