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
co-authored by Cursor
parent 4ef56aeb8f
commit 0edf4a789c
12 changed files with 2163 additions and 112 deletions
+1885 -1
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -7,7 +7,9 @@
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
"dev": "tsx watch src/index.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@prisma/client": "^5.10.2",
@@ -26,8 +28,11 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.11.24",
"@types/supertest": "^6.0.3",
"@types/web-push": "^3.6.4",
"supertest": "^7.1.0",
"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 { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
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'
import { createApp } from './app.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
dotenv.config({ path: resolve(__dirname, '../../.env') })
dotenv.config({ path: resolve(__dirname, '../.env') })
const app = express()
const app = createApp()
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, () => {
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'
const MIN_SUBMIT_MS = 2_000
@@ -69,7 +69,11 @@ export const feedbackLimiter = rateLimit({
max: 5,
standardHeaders: true,
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) => {
res.status(429).json({
error: 'Too many feedback submissions. Please try again later.',
+2 -1
View File
@@ -12,5 +12,6 @@
"forceConsistentCasingInFileNames": 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'
}
}
})