feat(quality): Sprint 2 pre-deploy gates and server smoke tests

Extract Express app factory for testability, add Vitest/Supertest API
smoke tests, root npm run check script, and deployment docs. Fix
express-rate-limit IPv6 keyGenerator for feedback limiter.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 15:17:46 +02:00
parent 4ef56aeb8f
commit 0edf4a789c
12 changed files with 2163 additions and 112 deletions
+12 -3
View File
@@ -219,13 +219,19 @@ cd server && npx prisma db push && cd ..
| Health Check | http://localhost:5000/api/health | | Health Check | http://localhost:5000/api/health |
| Public Demo | http://localhost:5173/demo | | Public Demo | http://localhost:5173/demo |
### 5. Tests (Frontend) ### 5. Qualität & Tests
Vor jedem Deploy auf [kapteins-daagbok.eu](https://kapteins-daagbok.eu/) (kein externes CI):
```bash ```bash
cd client && npm test npm run check
# oder: ./scripts/predeploy-check.sh
``` ```
Vitest-Unit-Tests für Utils, i18n und Services (z. B. Kurswinkel, Benutzereinstellungen). Einzeln: `npm test` (Client + Server) · `npm run build` · optional `npm run lint` (Client, noch nicht in `check`)
- **Client:** Vitest für Utils, i18n, Services
- **Server:** Smoke-Tests (`/api/health`, Auth-Guards) mit Supertest — siehe `server/src/api.smoke.test.ts`
## Docker (produktionsnah) ## Docker (produktionsnah)
@@ -241,6 +247,8 @@ Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `htt
## Deployment ## Deployment
**Vor dem Deploy:** `npm run check` lokal ausführen.
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen): Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
```bash ```bash
@@ -258,6 +266,7 @@ Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deploymen
| Dokument | Inhalt | | Dokument | Inhalt |
|----------|--------| |----------|--------|
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header | | [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle | | [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics | | [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan | | [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
+38
View File
@@ -0,0 +1,38 @@
# Pre-Deploy-Checks (ohne CI)
Vor jedem Update auf **https://kapteins-daagbok.eu/** lokal ausführen:
```bash
npm run check
```
Das Skript [`scripts/predeploy-check.sh`](../../scripts/predeploy-check.sh) führt aus:
1. i18n-Key-Validierung (`validate:i18n`)
2. Client: `test``build` (TypeScript via `tsc -b`)
3. Server: `test``build`
## Einzelbefehle (Repo-Root)
| Befehl | Inhalt |
|--------|--------|
| `npm run lint` | ESLint (Client) — optional, noch nicht Teil von `check` |
| `npm run test` | Vitest Client + Server |
| `npm run build` | Production-Build beider Pakete |
| `npm run predeploy` | Alias für `npm run check` |
## Server-Tests
Smoke-Tests in `server/src/api.smoke.test.ts` — keine echte Datenbank (Prisma gemockt). Prüfen u. a. Health, 401 ohne Session, öffentliche Collaboration-Validierung.
```bash
cd server && npm test
```
## Nach erfolgreichem Check
```bash
./scripts/update-prod.sh
```
Oder manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
+6 -1
View File
@@ -7,6 +7,11 @@
"translate:flyer": "node scripts/translate-flyer.mjs", "translate:flyer": "node scripts/translate-flyer.mjs",
"validate:i18n": "node scripts/validate-i18n-keys.mjs", "validate:i18n": "node scripts/validate-i18n-keys.mjs",
"generate:flyer": "node scripts/generate-beta-flyer.mjs", "generate:flyer": "node scripts/generate-beta-flyer.mjs",
"generate:flyer:all": "node scripts/generate-beta-flyer.mjs --all" "generate:flyer:all": "node scripts/generate-beta-flyer.mjs --all",
"lint": "npm run lint --prefix client",
"test": "npm run test --prefix client && npm run test --prefix server",
"build": "npm run build --prefix client && npm run build --prefix server",
"check": "bash scripts/predeploy-check.sh",
"predeploy": "npm run check"
} }
} }
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Local quality gates before deploying to kapteins-daagbok.eu (no external CI).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
echo "=================================================="
echo " Kapteins Daagbok — pre-deploy checks"
echo "=================================================="
run() {
echo ""
echo "==> $*"
"$@"
}
run npm run validate:i18n
pushd client >/dev/null
if [ ! -d node_modules ]; then
run npm ci
fi
# Lint: run separately with `npm run lint` (client ESLint; cleanup tracked separately)
run npm run test
run npm run build
popd >/dev/null
pushd server >/dev/null
if [ ! -d node_modules ]; then
run npm ci
fi
run npm run test
run npm run build
popd >/dev/null
echo ""
echo "=================================================="
echo " All pre-deploy checks passed."
echo "=================================================="
+1885 -1
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -7,7 +7,9 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "tsx watch src/index.ts" "dev": "tsx watch src/index.ts",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.10.2", "@prisma/client": "^5.10.2",
@@ -26,8 +28,11 @@
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.11.24", "@types/node": "^20.11.24",
"@types/supertest": "^6.0.3",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"supertest": "^7.1.0",
"tsx": "^4.7.1", "tsx": "^4.7.1",
"typescript": "^5.3.3" "typescript": "^5.3.3",
"vitest": "^3.0.9"
} }
} }
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeAll } from 'vitest'
import request from 'supertest'
vi.mock('./db.js', () => ({
prisma: {
$queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1 }])
}
}))
const { createApp } = await import('./app.js')
describe('API smoke', () => {
const app = createApp()
beforeAll(() => {
process.env.SESSION_SECRET =
process.env.SESSION_SECRET ?? 'test-session-secret-minimum-32-characters-long'
process.env.ORIGIN = process.env.ORIGIN ?? 'http://localhost:5173'
process.env.RP_ID = process.env.RP_ID ?? 'localhost'
})
it('GET /api/health returns ok when database is reachable', async () => {
const res = await request(app).get('/api/health')
expect(res.status).toBe(200)
expect(res.body.status).toBe('ok')
expect(res.body.database).toBe('connected')
})
it('GET /api/logbooks requires session', async () => {
const res = await request(app).get('/api/logbooks')
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
it('POST /api/sync/push requires session', async () => {
const res = await request(app)
.post('/api/sync/push')
.send({ items: [] })
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
it('GET /api/collaboration/invite-details requires token query', async () => {
const res = await request(app).get('/api/collaboration/invite-details')
expect(res.status).toBe(400)
expect(res.body.error).toMatch(/Token/i)
})
})
+103
View File
@@ -0,0 +1,103 @@
import express from 'express'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import authRouter from './routes/auth.js'
import logbooksRouter from './routes/logbooks.js'
import syncRouter from './routes/sync.js'
import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js'
import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js'
import feedbackRouter from './routes/feedback.js'
import { prisma } from './db.js'
import { buildCorsOptions } from './cors.js'
/** Behind Nginx Proxy Manager. See docs/deployment/npm-security.md */
function configureTrustProxy(app: express.Express): void {
const raw = process.env.TRUST_PROXY?.trim()
if (raw === '1' || raw === 'true') {
app.set('trust proxy', 1)
return
}
if (raw) {
app.set('trust proxy', raw)
return
}
if (process.env.NODE_ENV === 'production') {
app.set('trust proxy', 1)
}
}
export function createApp(): express.Express {
const app = express()
configureTrustProxy(app)
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
})
)
app.use(cors(buildCorsOptions()))
app.use(cookieParser())
app.use(express.json({ limit: '50mb' }))
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false
})
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false
})
const publicCollaborationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false
})
app.use('/api/auth', authLimiter)
app.use('/api/collaboration/invite-details', publicCollaborationLimiter)
app.use('/api/collaboration/share-pull', publicCollaborationLimiter)
app.use('/api', apiLimiter)
app.use('/api/auth', authRouter)
app.use('/api/logbooks', logbooksRouter)
app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter)
app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter)
app.use('/api/feedback', feedbackRouter)
app.get('/api/health', async (_req, res) => {
try {
await prisma.$queryRaw`SELECT 1`
res.json({
status: 'ok',
database: 'connected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
} catch {
res.status(500).json({
status: 'error',
database: 'disconnected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
}
})
return app
}
+2 -102
View File
@@ -1,116 +1,16 @@
import express from 'express'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import { dirname, resolve } from 'node:path' import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import authRouter from './routes/auth.js' import { createApp } from './app.js'
import logbooksRouter from './routes/logbooks.js'
import syncRouter from './routes/sync.js'
import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js'
import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js'
import feedbackRouter from './routes/feedback.js'
import { prisma } from './db.js'
import { buildCorsOptions } from './cors.js'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
dotenv.config({ path: resolve(__dirname, '../../.env') }) dotenv.config({ path: resolve(__dirname, '../../.env') })
dotenv.config({ path: resolve(__dirname, '../.env') }) dotenv.config({ path: resolve(__dirname, '../.env') })
const app = express() const app = createApp()
const PORT = process.env.PORT || 5000 const PORT = process.env.PORT || 5000
/** Behind Nginx Proxy Manager (172.16.10.10 → app). See docs/deployment/npm-security.md */
function configureTrustProxy(): void {
const raw = process.env.TRUST_PROXY?.trim()
if (raw === '1' || raw === 'true') {
app.set('trust proxy', 1)
return
}
if (raw) {
app.set('trust proxy', raw)
return
}
if (process.env.NODE_ENV === 'production') {
app.set('trust proxy', 1)
}
}
configureTrustProxy()
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
})
)
app.use(cors(buildCorsOptions()))
app.use(cookieParser())
// Encrypted sync payloads (photos, GPS tracks) can be large — align with nginx client_max_body_size
app.use(express.json({ limit: '50mb' }))
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false
})
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false
})
/** Unauthenticated collaboration reads — stricter than global API limit */
const publicCollaborationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false
})
app.use('/api/auth', authLimiter)
app.use('/api/collaboration/invite-details', publicCollaborationLimiter)
app.use('/api/collaboration/share-pull', publicCollaborationLimiter)
app.use('/api', apiLimiter)
// Mount routes
app.use('/api/auth', authRouter)
app.use('/api/logbooks', logbooksRouter)
app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter)
app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter)
app.use('/api/feedback', feedbackRouter)
// Health check endpoint
app.get('/api/health', async (req, res) => {
try {
await prisma.$queryRaw`SELECT 1`
res.json({
status: 'ok',
database: 'connected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
} catch {
res.status(500).json({
status: 'error',
database: 'disconnected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
}
})
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`[server] Server running on http://localhost:${PORT}`) console.log(`[server] Server running on http://localhost:${PORT}`)
}) })
+6 -2
View File
@@ -1,4 +1,4 @@
import rateLimit from 'express-rate-limit' import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
import type { AuthedRequest } from './auth.js' import type { AuthedRequest } from './auth.js'
const MIN_SUBMIT_MS = 2_000 const MIN_SUBMIT_MS = 2_000
@@ -69,7 +69,11 @@ export const feedbackLimiter = rateLimit({
max: 5, max: 5,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
keyGenerator: (req) => (req as AuthedRequest).userId ?? req.ip ?? 'unknown', keyGenerator: (req) => {
const authed = req as AuthedRequest
if (authed.userId) return authed.userId
return ipKeyGenerator(req.ip ?? 'unknown')
},
handler: (_req, res) => { handler: (_req, res) => {
res.status(429).json({ res.status(429).json({
error: 'Too many feedback submissions. Please try again later.', error: 'Too many feedback submissions. Please try again later.',
+2 -1
View File
@@ -12,5 +12,6 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true "resolveJsonModule": true
}, },
"include": ["src/**/*"] "include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
} }
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
env: {
NODE_ENV: 'test',
SESSION_SECRET: 'test-session-secret-minimum-32-characters-long',
ORIGIN: 'http://localhost:5173',
RP_ID: 'localhost'
}
}
})