Add comprehensive backend test suite (#339)
* add test suite, mostly covers integration testing, tests are only backend side * workflow runs the correct script * workflow runs the correct script * workflow runs the correct script * unit tests incoming * Fix multer silent rejections and error handler info leak - Revert cb(null, false) to cb(new Error(...)) in auth.ts, collab.ts, and files.ts so invalid uploads return an error instead of silently dropping the file - Error handler in app.ts now always returns 500 / "Internal server error" instead of forwarding err.message to the client * Use statusCode consistently for multer errors and error handler - Error handler in app.ts reads err.statusCode to forward the correct HTTP status while keeping the response body generic
This commit is contained in:
41
.github/workflows/test.yml
vendored
Normal file
41
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
paths:
|
||||||
|
- 'server/**'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
paths:
|
||||||
|
- 'server/**'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
server-tests:
|
||||||
|
name: Server Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: server/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd server && npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd server && npm run test:coverage
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
if: success()
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: coverage
|
||||||
|
path: server/coverage/
|
||||||
|
retention-days: 7
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -56,3 +56,5 @@ coverage
|
|||||||
.cache
|
.cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
|
.scannerwork
|
||||||
@@ -18,7 +18,7 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
|||||||
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||||
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
|
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
|
||||||
import PermissionsPanel from '../components/Admin/PermissionsPanel'
|
import PermissionsPanel from '../components/Admin/PermissionsPanel'
|
||||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, GitBranch, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
|
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||||
import CustomSelect from '../components/shared/CustomSelect'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
|
|
||||||
interface AdminUser {
|
interface AdminUser {
|
||||||
|
|||||||
2127
server/package-lock.json
generated
2127
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,13 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --import tsx src/index.ts",
|
||||||
"dev": "tsx watch src/index.ts"
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:unit": "vitest run tests/unit",
|
||||||
|
"test:integration": "vitest run tests/integration",
|
||||||
|
"test:ws": "vitest run tests/websocket",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
@@ -43,9 +49,13 @@
|
|||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
"@types/unzipper": "^0.10.11",
|
"@types/unzipper": "^0.10.11",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"nodemon": "^3.1.0"
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"nodemon": "^3.1.0",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
243
server/src/app.ts
Normal file
243
server/src/app.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { JWT_SECRET } from './config';
|
||||||
|
import { logDebug, logWarn, logError } from './services/auditLog';
|
||||||
|
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
||||||
|
import { authenticate } from './middleware/auth';
|
||||||
|
import { db } from './db/database';
|
||||||
|
|
||||||
|
import authRoutes from './routes/auth';
|
||||||
|
import tripsRoutes from './routes/trips';
|
||||||
|
import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days';
|
||||||
|
import placesRoutes from './routes/places';
|
||||||
|
import assignmentsRoutes from './routes/assignments';
|
||||||
|
import packingRoutes from './routes/packing';
|
||||||
|
import tagsRoutes from './routes/tags';
|
||||||
|
import categoriesRoutes from './routes/categories';
|
||||||
|
import adminRoutes from './routes/admin';
|
||||||
|
import mapsRoutes from './routes/maps';
|
||||||
|
import filesRoutes from './routes/files';
|
||||||
|
import reservationsRoutes from './routes/reservations';
|
||||||
|
import dayNotesRoutes from './routes/dayNotes';
|
||||||
|
import weatherRoutes from './routes/weather';
|
||||||
|
import settingsRoutes from './routes/settings';
|
||||||
|
import budgetRoutes from './routes/budget';
|
||||||
|
import collabRoutes from './routes/collab';
|
||||||
|
import backupRoutes from './routes/backup';
|
||||||
|
import oidcRoutes from './routes/oidc';
|
||||||
|
import vacayRoutes from './routes/vacay';
|
||||||
|
import atlasRoutes from './routes/atlas';
|
||||||
|
import immichRoutes from './routes/immich';
|
||||||
|
import notificationRoutes from './routes/notifications';
|
||||||
|
import shareRoutes from './routes/share';
|
||||||
|
import { mcpHandler } from './mcp';
|
||||||
|
import { Addon } from './types';
|
||||||
|
|
||||||
|
export function createApp(): express.Application {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Trust first proxy (nginx/Docker) for correct req.ip
|
||||||
|
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
|
||||||
|
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||||
|
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let corsOrigin: cors.CorsOptions['origin'];
|
||||||
|
if (allowedOrigins) {
|
||||||
|
corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
|
||||||
|
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
||||||
|
else callback(new Error('Not allowed by CORS'));
|
||||||
|
};
|
||||||
|
} else if (process.env.NODE_ENV === 'production') {
|
||||||
|
corsOrigin = false;
|
||||||
|
} else {
|
||||||
|
corsOrigin = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
||||||
|
|
||||||
|
app.use(cors({ origin: corsOrigin, credentials: true }));
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
||||||
|
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||||
|
connectSrc: [
|
||||||
|
"'self'", "ws:", "wss:",
|
||||||
|
"https://nominatim.openstreetmap.org", "https://overpass-api.de",
|
||||||
|
"https://places.googleapis.com", "https://api.openweathermap.org",
|
||||||
|
"https://en.wikipedia.org", "https://commons.wikimedia.org",
|
||||||
|
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
||||||
|
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||||
|
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||||
|
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson"
|
||||||
|
],
|
||||||
|
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||||
|
objectSrc: ["'none'"],
|
||||||
|
frameSrc: ["'none'"],
|
||||||
|
frameAncestors: ["'self'"],
|
||||||
|
upgradeInsecureRequests: shouldForceHttps ? [] : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (shouldForceHttps) {
|
||||||
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
||||||
|
res.redirect(301, 'https://' + req.headers.host + req.url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '100kb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(enforceGlobalMfaPolicy);
|
||||||
|
|
||||||
|
// Request logging with sensitive field redaction
|
||||||
|
{
|
||||||
|
const SENSITIVE_KEYS = new Set(['password', 'new_password', 'current_password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code', 'smtp_pass']);
|
||||||
|
const redact = (value: unknown): unknown => {
|
||||||
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
if (Array.isArray(value)) return (value as unknown[]).map(redact);
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (req.path === '/api/health') return next();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
res.on('finish', () => {
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
if (res.statusCode >= 500) {
|
||||||
|
logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||||
|
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
||||||
|
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||||
|
} else if (res.statusCode >= 400) {
|
||||||
|
logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||||
|
}
|
||||||
|
const q = Object.keys(req.query).length ? ` query=${JSON.stringify(redact(req.query))}` : '';
|
||||||
|
const b = req.body && Object.keys(req.body).length ? ` body=${JSON.stringify(redact(req.body))}` : '';
|
||||||
|
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}${q}${b}`);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static: avatars and covers are public
|
||||||
|
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
||||||
|
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
|
||||||
|
|
||||||
|
// Photos require auth or valid share token
|
||||||
|
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
|
||||||
|
const safeName = path.basename(req.params.filename);
|
||||||
|
const filePath = path.join(__dirname, '../uploads/photos', safeName);
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) {
|
||||||
|
return res.status(403).send('Forbidden');
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const token = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
|
||||||
|
if (!token) return res.status(401).send('Authentication required');
|
||||||
|
|
||||||
|
try {
|
||||||
|
jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
|
} catch {
|
||||||
|
const shareRow = db.prepare('SELECT id FROM share_tokens WHERE token = ?').get(token);
|
||||||
|
if (!shareRow) return res.status(401).send('Authentication required');
|
||||||
|
}
|
||||||
|
res.sendFile(resolved);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Block direct access to /uploads/files
|
||||||
|
app.use('/uploads/files', (_req: Request, res: Response) => {
|
||||||
|
res.status(401).send('Authentication required');
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/auth/oidc', oidcRoutes);
|
||||||
|
app.use('/api/trips', tripsRoutes);
|
||||||
|
app.use('/api/trips/:tripId/days', daysRoutes);
|
||||||
|
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
|
||||||
|
app.use('/api/trips/:tripId/places', placesRoutes);
|
||||||
|
app.use('/api/trips/:tripId/packing', packingRoutes);
|
||||||
|
app.use('/api/trips/:tripId/files', filesRoutes);
|
||||||
|
app.use('/api/trips/:tripId/budget', budgetRoutes);
|
||||||
|
app.use('/api/trips/:tripId/collab', collabRoutes);
|
||||||
|
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||||
|
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
||||||
|
app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' }));
|
||||||
|
app.use('/api', assignmentsRoutes);
|
||||||
|
app.use('/api/tags', tagsRoutes);
|
||||||
|
app.use('/api/categories', categoriesRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
|
||||||
|
// Addons list endpoint
|
||||||
|
app.get('/api/addons', authenticate, (_req: Request, res: Response) => {
|
||||||
|
const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
|
||||||
|
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Addon routes
|
||||||
|
app.use('/api/addons/vacay', vacayRoutes);
|
||||||
|
app.use('/api/addons/atlas', atlasRoutes);
|
||||||
|
app.use('/api/integrations/immich', immichRoutes);
|
||||||
|
app.use('/api/maps', mapsRoutes);
|
||||||
|
app.use('/api/weather', weatherRoutes);
|
||||||
|
app.use('/api/settings', settingsRoutes);
|
||||||
|
app.use('/api/backup', backupRoutes);
|
||||||
|
app.use('/api/notifications', notificationRoutes);
|
||||||
|
app.use('/api', shareRoutes);
|
||||||
|
|
||||||
|
// MCP endpoint
|
||||||
|
app.post('/mcp', mcpHandler);
|
||||||
|
app.get('/mcp', mcpHandler);
|
||||||
|
app.delete('/mcp', mcpHandler);
|
||||||
|
|
||||||
|
// Production static file serving
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
const publicPath = path.join(__dirname, '../public');
|
||||||
|
app.use(express.static(publicPath, {
|
||||||
|
setHeaders: (res, filePath) => {
|
||||||
|
if (filePath.endsWith('index.html')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
app.get('*', (_req: Request, res: Response) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.sendFile(path.join(publicPath, 'index.html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
app.use((err: Error & { status?: number; statusCode?: number }, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
console.error('Unhandled error:', err.message);
|
||||||
|
} else {
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
}
|
||||||
|
const status = err.statusCode || 500;
|
||||||
|
res.status(status).json({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'fs';
|
import fs from 'node:fs';
|
||||||
import path from 'path';
|
import path from 'node:path';
|
||||||
|
|
||||||
const dataDir = path.resolve(__dirname, '../data');
|
const dataDir = path.resolve(__dirname, '../data');
|
||||||
|
|
||||||
|
|||||||
@@ -1,273 +1,29 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express, { Request, Response, NextFunction } from 'express';
|
import path from 'node:path';
|
||||||
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
import fs from 'node:fs';
|
||||||
import cors from 'cors';
|
import { createApp } from './app';
|
||||||
import helmet from 'helmet';
|
|
||||||
import cookieParser from 'cookie-parser';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
const app = express();
|
// Create upload and data directories on startup
|
||||||
const DEBUG = String(process.env.DEBUG || 'false').toLowerCase() === 'true';
|
|
||||||
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
|
||||||
|
|
||||||
// Trust first proxy (nginx/Docker) for correct req.ip
|
|
||||||
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
|
|
||||||
app.set('trust proxy', parseInt(process.env.TRUST_PROXY as string) || 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create upload directories on startup
|
|
||||||
const uploadsDir = path.join(__dirname, '../uploads');
|
const uploadsDir = path.join(__dirname, '../uploads');
|
||||||
const photosDir = path.join(uploadsDir, 'photos');
|
const photosDir = path.join(uploadsDir, 'photos');
|
||||||
const filesDir = path.join(uploadsDir, 'files');
|
const filesDir = path.join(uploadsDir, 'files');
|
||||||
const coversDir = path.join(uploadsDir, 'covers');
|
const coversDir = path.join(uploadsDir, 'covers');
|
||||||
|
const avatarsDir = path.join(uploadsDir, 'avatars');
|
||||||
const backupsDir = path.join(__dirname, '../data/backups');
|
const backupsDir = path.join(__dirname, '../data/backups');
|
||||||
const tmpDir = path.join(__dirname, '../data/tmp');
|
const tmpDir = path.join(__dirname, '../data/tmp');
|
||||||
|
|
||||||
[uploadsDir, photosDir, filesDir, coversDir, backupsDir, tmpDir].forEach(dir => {
|
[uploadsDir, photosDir, filesDir, coversDir, avatarsDir, backupsDir, tmpDir].forEach(dir => {
|
||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Middleware
|
const app = createApp();
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
|
||||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
let corsOrigin: cors.CorsOptions['origin'];
|
|
||||||
if (allowedOrigins) {
|
|
||||||
corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
|
|
||||||
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
|
||||||
else callback(new Error('Not allowed by CORS'));
|
|
||||||
};
|
|
||||||
} else if (process.env.NODE_ENV === 'production') {
|
|
||||||
corsOrigin = false;
|
|
||||||
} else {
|
|
||||||
corsOrigin = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
|
||||||
|
|
||||||
app.use(cors({
|
|
||||||
origin: corsOrigin,
|
|
||||||
credentials: true
|
|
||||||
}));
|
|
||||||
app.use(helmet({
|
|
||||||
contentSecurityPolicy: {
|
|
||||||
directives: {
|
|
||||||
defaultSrc: ["'self'"],
|
|
||||||
scriptSrc: ["'self'"],
|
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
|
||||||
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
|
||||||
connectSrc: [
|
|
||||||
"'self'", "ws:", "wss:",
|
|
||||||
"https://nominatim.openstreetmap.org", "https://overpass-api.de",
|
|
||||||
"https://places.googleapis.com", "https://api.openweathermap.org",
|
|
||||||
"https://en.wikipedia.org", "https://commons.wikimedia.org",
|
|
||||||
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
|
||||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
|
||||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
|
||||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson"
|
|
||||||
],
|
|
||||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
|
||||||
objectSrc: ["'none'"],
|
|
||||||
frameSrc: ["'none'"],
|
|
||||||
frameAncestors: ["'self'"],
|
|
||||||
upgradeInsecureRequests: shouldForceHttps ? [] : null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
crossOriginEmbedderPolicy: false,
|
|
||||||
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true)
|
|
||||||
if (shouldForceHttps) {
|
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
||||||
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
|
||||||
res.redirect(301, 'https://' + req.headers.host + req.url);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
app.use(express.json({ limit: '100kb' }));
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
app.use(cookieParser());
|
|
||||||
|
|
||||||
app.use(enforceGlobalMfaPolicy);
|
|
||||||
|
|
||||||
{
|
|
||||||
const { logInfo: _logInfo, logDebug: _logDebug, logWarn: _logWarn, logError: _logError } = require('./services/auditLog');
|
|
||||||
const SENSITIVE_KEYS = new Set(['password', 'new_password', 'current_password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code', 'smtp_pass']);
|
|
||||||
const _redact = (value: unknown): unknown => {
|
|
||||||
if (!value || typeof value !== 'object') return value;
|
|
||||||
if (Array.isArray(value)) return value.map(_redact);
|
|
||||||
const out: Record<string, unknown> = {};
|
|
||||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
||||||
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : _redact(v);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
||||||
if (req.path === '/api/health') return next();
|
|
||||||
|
|
||||||
const startedAt = Date.now();
|
|
||||||
res.on('finish', () => {
|
|
||||||
const ms = Date.now() - startedAt;
|
|
||||||
|
|
||||||
if (res.statusCode >= 500) {
|
|
||||||
_logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
|
||||||
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
|
||||||
_logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
|
||||||
} else if (res.statusCode >= 400) {
|
|
||||||
_logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = Object.keys(req.query).length ? ` query=${JSON.stringify(_redact(req.query))}` : '';
|
|
||||||
const b = req.body && Object.keys(req.body).length ? ` body=${JSON.stringify(_redact(req.body))}` : '';
|
|
||||||
_logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}${q}${b}`);
|
|
||||||
});
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avatars are public (shown on login, sharing screens)
|
|
||||||
import { authenticate } from './middleware/auth';
|
|
||||||
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
|
||||||
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
|
|
||||||
|
|
||||||
// Serve uploaded photos — require auth token or valid share token
|
|
||||||
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
|
|
||||||
const safeName = path.basename(req.params.filename);
|
|
||||||
const filePath = path.join(__dirname, '../uploads/photos', safeName);
|
|
||||||
const resolved = path.resolve(filePath);
|
|
||||||
if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) {
|
|
||||||
return res.status(403).send('Forbidden');
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
|
|
||||||
|
|
||||||
// Allow if authenticated or if a valid share token is present
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
const token = req.query.token as string || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
|
|
||||||
if (!token) return res.status(401).send('Authentication required');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
jwt.verify(token, process.env.JWT_SECRET || require('./config').JWT_SECRET);
|
|
||||||
} catch {
|
|
||||||
// Check if it's a share token
|
|
||||||
const shareRow = addonDb.prepare('SELECT id FROM share_tokens WHERE token = ?').get(token);
|
|
||||||
if (!shareRow) return res.status(401).send('Authentication required');
|
|
||||||
}
|
|
||||||
res.sendFile(resolved);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Block direct access to /uploads/files — served via authenticated /api/trips/:tripId/files/:id/download
|
|
||||||
app.use('/uploads/files', (_req: Request, res: Response) => {
|
|
||||||
res.status(401).send('Authentication required');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Routes
|
|
||||||
import authRoutes from './routes/auth';
|
|
||||||
import tripsRoutes from './routes/trips';
|
|
||||||
import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days';
|
|
||||||
import placesRoutes from './routes/places';
|
|
||||||
import assignmentsRoutes from './routes/assignments';
|
|
||||||
import packingRoutes from './routes/packing';
|
|
||||||
import tagsRoutes from './routes/tags';
|
|
||||||
import categoriesRoutes from './routes/categories';
|
|
||||||
import adminRoutes from './routes/admin';
|
|
||||||
import mapsRoutes from './routes/maps';
|
|
||||||
import filesRoutes from './routes/files';
|
|
||||||
import reservationsRoutes from './routes/reservations';
|
|
||||||
import dayNotesRoutes from './routes/dayNotes';
|
|
||||||
import weatherRoutes from './routes/weather';
|
|
||||||
import settingsRoutes from './routes/settings';
|
|
||||||
import budgetRoutes from './routes/budget';
|
|
||||||
import collabRoutes from './routes/collab';
|
|
||||||
import backupRoutes from './routes/backup';
|
|
||||||
import oidcRoutes from './routes/oidc';
|
|
||||||
app.use('/api/auth', authRoutes);
|
|
||||||
app.use('/api/auth/oidc', oidcRoutes);
|
|
||||||
app.use('/api/trips', tripsRoutes);
|
|
||||||
app.use('/api/trips/:tripId/days', daysRoutes);
|
|
||||||
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
|
|
||||||
app.use('/api/trips/:tripId/places', placesRoutes);
|
|
||||||
app.use('/api/trips/:tripId/packing', packingRoutes);
|
|
||||||
app.use('/api/trips/:tripId/files', filesRoutes);
|
|
||||||
app.use('/api/trips/:tripId/budget', budgetRoutes);
|
|
||||||
app.use('/api/trips/:tripId/collab', collabRoutes);
|
|
||||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
|
||||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
|
||||||
app.get('/api/health', (req: Request, res: Response) => res.json({ status: 'ok' }));
|
|
||||||
app.use('/api', assignmentsRoutes);
|
|
||||||
app.use('/api/tags', tagsRoutes);
|
|
||||||
app.use('/api/categories', categoriesRoutes);
|
|
||||||
app.use('/api/admin', adminRoutes);
|
|
||||||
|
|
||||||
// Public addons endpoint (authenticated but not admin-only)
|
|
||||||
import { authenticate as addonAuth } from './middleware/auth';
|
|
||||||
import {db as addonDb} from './db/database';
|
|
||||||
import { Addon } from './types';
|
|
||||||
app.get('/api/addons', addonAuth, (req: Request, res: Response) => {
|
|
||||||
const addons = addonDb.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
|
|
||||||
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Addon routes
|
|
||||||
import vacayRoutes from './routes/vacay';
|
|
||||||
app.use('/api/addons/vacay', vacayRoutes);
|
|
||||||
import atlasRoutes from './routes/atlas';
|
|
||||||
app.use('/api/addons/atlas', atlasRoutes);
|
|
||||||
import immichRoutes from './routes/immich';
|
|
||||||
app.use('/api/integrations/immich', immichRoutes);
|
|
||||||
|
|
||||||
app.use('/api/maps', mapsRoutes);
|
|
||||||
app.use('/api/weather', weatherRoutes);
|
|
||||||
app.use('/api/settings', settingsRoutes);
|
|
||||||
app.use('/api/backup', backupRoutes);
|
|
||||||
|
|
||||||
import notificationRoutes from './routes/notifications';
|
|
||||||
app.use('/api/notifications', notificationRoutes);
|
|
||||||
|
|
||||||
import shareRoutes from './routes/share';
|
|
||||||
app.use('/api', shareRoutes);
|
|
||||||
|
|
||||||
// MCP endpoint (Streamable HTTP transport, per-user auth)
|
|
||||||
import { mcpHandler, closeMcpSessions } from './mcp';
|
|
||||||
app.post('/mcp', mcpHandler);
|
|
||||||
app.get('/mcp', mcpHandler);
|
|
||||||
app.delete('/mcp', mcpHandler);
|
|
||||||
|
|
||||||
// Serve static files in production
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
const publicPath = path.join(__dirname, '../public');
|
|
||||||
app.use(express.static(publicPath, {
|
|
||||||
setHeaders: (res, filePath) => {
|
|
||||||
// Never cache index.html so version updates are picked up immediately
|
|
||||||
if (filePath.endsWith('index.html')) {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
app.get('*', (req: Request, res: Response) => {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
res.sendFile(path.join(publicPath, 'index.html'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global error handler — do not leak stack traces in production
|
|
||||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.error('Unhandled error:', err);
|
|
||||||
} else {
|
|
||||||
console.error('Unhandled error:', err.message);
|
|
||||||
}
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
});
|
|
||||||
|
|
||||||
import * as scheduler from './scheduler';
|
import * as scheduler from './scheduler';
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const server = app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./services/auditLog');
|
const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./services/auditLog');
|
||||||
|
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
||||||
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
const origins = process.env.ALLOWED_ORIGINS || '(same-origin)';
|
const origins = process.env.ALLOWED_ORIGINS || '(same-origin)';
|
||||||
const banner = [
|
const banner = [
|
||||||
@@ -301,6 +57,7 @@ const server = app.listen(PORT, () => {
|
|||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
function shutdown(signal: string): void {
|
function shutdown(signal: string): void {
|
||||||
const { logInfo: sLogInfo, logError: sLogError } = require('./services/auditLog');
|
const { logInfo: sLogInfo, logError: sLogError } = require('./services/auditLog');
|
||||||
|
const { closeMcpSessions } = require('./mcp');
|
||||||
sLogInfo(`${signal} received — shutting down gracefully...`);
|
sLogInfo(`${signal} received — shutting down gracefully...`);
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
closeMcpSessions();
|
closeMcpSessions();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { db } from '../db/database';
|
|||||||
import { JWT_SECRET } from '../config';
|
import { JWT_SECRET } from '../config';
|
||||||
import { AuthRequest, OptionalAuthRequest, User } from '../types';
|
import { AuthRequest, OptionalAuthRequest, User } from '../types';
|
||||||
|
|
||||||
function extractToken(req: Request): string | null {
|
export function extractToken(req: Request): string | null {
|
||||||
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
|
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
|
||||||
const cookieToken = (req as any).cookies?.trek_session;
|
const cookieToken = (req as any).cookies?.trek_session;
|
||||||
if (cookieToken) return cookieToken;
|
if (cookieToken) return cookieToken;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { db } from '../db/database';
|
|||||||
import { JWT_SECRET } from '../config';
|
import { JWT_SECRET } from '../config';
|
||||||
|
|
||||||
/** Paths that never require MFA (public or pre-auth). */
|
/** Paths that never require MFA (public or pre-auth). */
|
||||||
function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
export function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
||||||
if (method === 'GET' && pathNoQuery === '/api/health') return true;
|
if (method === 'GET' && pathNoQuery === '/api/health') return true;
|
||||||
if (method === 'GET' && pathNoQuery === '/api/auth/app-config') return true;
|
if (method === 'GET' && pathNoQuery === '/api/auth/app-config') return true;
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/login') return true;
|
if (method === 'POST' && pathNoQuery === '/api/auth/login') return true;
|
||||||
@@ -17,7 +17,7 @@ function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Authenticated paths allowed while MFA is not yet enabled (setup + lockout recovery). */
|
/** Authenticated paths allowed while MFA is not yet enabled (setup + lockout recovery). */
|
||||||
function isMfaSetupExemptPath(method: string, pathNoQuery: string): boolean {
|
export function isMfaSetupExemptPath(method: string, pathNoQuery: string): boolean {
|
||||||
if (method === 'GET' && pathNoQuery === '/api/auth/me') return true;
|
if (method === 'GET' && pathNoQuery === '/api/auth/me') return true;
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true;
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true;
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ const avatarUpload = multer({
|
|||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
|
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
|
||||||
return cb(new Error('Only .jpg, .jpeg, .png, .gif, .webp images are allowed'));
|
const err: Error & { statusCode?: number } = new Error('Only image files (jpg, png, gif, webp) are allowed');
|
||||||
|
err.statusCode = 400;
|
||||||
|
return cb(err);
|
||||||
}
|
}
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
},
|
},
|
||||||
@@ -321,3 +323,6 @@ router.post('/resource-token', authenticate, (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
// Exported for test resets only — do not use in production code
|
||||||
|
export { loginAttempts, mfaAttempts };
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ const noteUpload = multer({
|
|||||||
const ext = path.extname(file.originalname).toLowerCase();
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
const BLOCKED = ['.svg', '.html', '.htm', '.xml', '.xhtml', '.js', '.jsx', '.ts', '.exe', '.bat', '.sh', '.cmd', '.msi', '.dll', '.com', '.vbs', '.ps1', '.php'];
|
const BLOCKED = ['.svg', '.html', '.htm', '.xml', '.xhtml', '.js', '.jsx', '.ts', '.exe', '.bat', '.sh', '.cmd', '.msi', '.dll', '.com', '.vbs', '.ps1', '.php'];
|
||||||
if (BLOCKED.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
|
if (BLOCKED.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
|
||||||
return cb(new Error('File type not allowed'));
|
const err: Error & { statusCode?: number } = new Error('File type not allowed');
|
||||||
|
err.statusCode = 400;
|
||||||
|
return cb(err);
|
||||||
}
|
}
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,14 +57,18 @@ const upload = multer({
|
|||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) {
|
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) {
|
||||||
return cb(new Error('File type not allowed'));
|
const err: Error & { statusCode?: number } = new Error('File type not allowed');
|
||||||
|
err.statusCode = 400;
|
||||||
|
return cb(err);
|
||||||
}
|
}
|
||||||
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
|
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
|
||||||
const fileExt = ext.replace('.', '');
|
const fileExt = ext.replace('.', '');
|
||||||
if (allowed.includes(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) {
|
if (allowed.includes(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('File type not allowed'));
|
const err: Error & { statusCode?: number } = new Error('File type not allowed');
|
||||||
|
err.statusCode = 400;
|
||||||
|
cb(err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,21 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
|||||||
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.put('/reorder', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const { orderedIds } = req.body;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||||
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
|
reorderItems(tripId, orderedIds);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
@@ -220,19 +235,4 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/reorder', authenticate, (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { tripId } = req.params;
|
|
||||||
const { orderedIds } = req.body;
|
|
||||||
|
|
||||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
|
||||||
|
|
||||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
|
||||||
return res.status(403).json({ error: 'No permission' });
|
|
||||||
|
|
||||||
reorderItems(tripId, orderedIds);
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import cron, { type ScheduledTask } from 'node-cron';
|
import cron, { type ScheduledTask } from 'node-cron';
|
||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import path from 'path';
|
import path from 'node:path';
|
||||||
import fs from 'fs';
|
import fs from 'node:fs';
|
||||||
|
|
||||||
const dataDir = path.join(__dirname, '../data');
|
const dataDir = path.join(__dirname, '../data');
|
||||||
const backupsDir = path.join(dataDir, 'backups');
|
const backupsDir = path.join(dataDir, 'backups');
|
||||||
@@ -9,8 +9,8 @@ const uploadsDir = path.join(__dirname, '../uploads');
|
|||||||
const settingsFile = path.join(dataDir, 'backup-settings.json');
|
const settingsFile = path.join(dataDir, 'backup-settings.json');
|
||||||
|
|
||||||
const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
|
const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
|
||||||
const VALID_DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; // 0=Sunday
|
const VALID_DAYS_OF_WEEK = new Set([0, 1, 2, 3, 4, 5, 6]); // 0=Sunday
|
||||||
const VALID_HOURS = Array.from({ length: 24 }, (_, i) => i);
|
const VALID_HOURS = new Set(Array.from({length: 24}, (_, i) => i));
|
||||||
|
|
||||||
interface BackupSettings {
|
interface BackupSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -21,9 +21,9 @@ interface BackupSettings {
|
|||||||
day_of_month: number;
|
day_of_month: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCronExpression(settings: BackupSettings): string {
|
export function buildCronExpression(settings: BackupSettings): string {
|
||||||
const hour = VALID_HOURS.includes(settings.hour) ? settings.hour : 2;
|
const hour = VALID_HOURS.has(settings.hour) ? settings.hour : 2;
|
||||||
const dow = VALID_DAYS_OF_WEEK.includes(settings.day_of_week) ? settings.day_of_week : 0;
|
const dow = VALID_DAYS_OF_WEEK.has(settings.day_of_week) ? settings.day_of_week : 0;
|
||||||
const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1;
|
const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1;
|
||||||
|
|
||||||
switch (settings.interval) {
|
switch (settings.interval) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'node:crypto';
|
||||||
import { ENCRYPTION_KEY } from '../config';
|
import { ENCRYPTION_KEY } from '../config';
|
||||||
|
|
||||||
const ENCRYPTED_PREFIX = 'enc:v1:';
|
const ENCRYPTED_PREFIX = 'enc:v1:';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Response } from 'express';
|
|||||||
|
|
||||||
const COOKIE_NAME = 'trek_session';
|
const COOKIE_NAME = 'trek_session';
|
||||||
|
|
||||||
function cookieOptions(clear = false) {
|
export function cookieOptions(clear = false) {
|
||||||
const secure = process.env.COOKIE_SECURE !== 'false' && (process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true');
|
const secure = process.env.COOKIE_SECURE !== 'false' && (process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true');
|
||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
|||||||
@@ -175,14 +175,14 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get localized event text
|
// Get localized event text
|
||||||
function getEventText(lang: string, event: EventType, params: Record<string, string>): EventText {
|
export function getEventText(lang: string, event: EventType, params: Record<string, string>): EventText {
|
||||||
const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
|
const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
|
||||||
return texts[event](params);
|
return texts[event](params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Email HTML builder ─────────────────────────────────────────────────────
|
// ── Email HTML builder ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildEmailHtml(subject: string, body: string, lang: string): string {
|
export function buildEmailHtml(subject: string, body: string, lang: string): string {
|
||||||
const s = I18N[lang] || I18N.en;
|
const s = I18N[lang] || I18N.en;
|
||||||
const appUrl = getAppUrl();
|
const appUrl = getAppUrl();
|
||||||
const ctaHref = appUrl || '#';
|
const ctaHref = appUrl || '#';
|
||||||
@@ -256,7 +256,7 @@ async function sendEmail(to: string, subject: string, body: string, userId?: num
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string }): string {
|
export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string }): string {
|
||||||
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
|
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
|
||||||
const isSlack = /hooks\.slack\.com\//.test(url);
|
const isSlack = /hooks\.slack\.com\//.test(url);
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
|
|||||||
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
|
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
function cacheKey(lat: string, lng: string, date?: string): string {
|
export function cacheKey(lat: string, lng: string, date?: string): string {
|
||||||
const rlat = parseFloat(lat).toFixed(2);
|
const rlat = parseFloat(lat).toFixed(2);
|
||||||
const rlng = parseFloat(lng).toFixed(2);
|
const rlng = parseFloat(lng).toFixed(2);
|
||||||
return `${rlat}_${rlng}_${date || 'current'}`;
|
return `${rlat}_${rlng}_${date || 'current'}`;
|
||||||
@@ -138,7 +138,7 @@ function setCache(key: string, data: WeatherResult, ttlMs: number): void {
|
|||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function estimateCondition(tempAvg: number, precipMm: number): string {
|
export function estimateCondition(tempAvg: number, precipMm: number): string {
|
||||||
if (precipMm > 5) return tempAvg <= 0 ? 'Snow' : 'Rain';
|
if (precipMm > 5) return tempAvg <= 0 ? 'Snow' : 'Rain';
|
||||||
if (precipMm > 1) return tempAvg <= 0 ? 'Snow' : 'Drizzle';
|
if (precipMm > 1) return tempAvg <= 0 ? 'Snow' : 'Drizzle';
|
||||||
if (precipMm > 0.3) return 'Clouds';
|
if (precipMm > 0.3) return 'Clouds';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import dns from 'dns/promises';
|
import dns from 'node:dns/promises';
|
||||||
import http from 'http';
|
import http from 'node:http';
|
||||||
import https from 'https';
|
import https from 'node:https';
|
||||||
|
|
||||||
const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK === 'true';
|
const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK === 'true';
|
||||||
|
|
||||||
@@ -17,11 +17,11 @@ function isAlwaysBlocked(ip: string): boolean {
|
|||||||
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
||||||
|
|
||||||
// Loopback
|
// Loopback
|
||||||
if (/^127\./.test(addr) || addr === '::1') return true;
|
if (addr.startsWith("127.") || addr === '::1') return true;
|
||||||
// Unspecified
|
// Unspecified
|
||||||
if (/^0\./.test(addr)) return true;
|
if (addr.startsWith("0.")) return true;
|
||||||
// Link-local / cloud metadata
|
// Link-local / cloud metadata
|
||||||
if (/^169\.254\./.test(addr) || /^fe80:/i.test(addr)) return true;
|
if (addr.startsWith("169.254.") || /^fe80:/i.test(addr)) return true;
|
||||||
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
|
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
|
||||||
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
|
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
|
||||||
|
|
||||||
@@ -33,9 +33,9 @@ function isPrivateNetwork(ip: string): boolean {
|
|||||||
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
||||||
|
|
||||||
// RFC-1918 private ranges
|
// RFC-1918 private ranges
|
||||||
if (/^10\./.test(addr)) return true;
|
if (addr.startsWith("10.")) return true;
|
||||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
|
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
|
||||||
if (/^192\.168\./.test(addr)) return true;
|
if (addr.startsWith("192.168.")) return true;
|
||||||
// CGNAT / Tailscale shared address space (100.64.0.0/10)
|
// CGNAT / Tailscale shared address space (100.64.0.0/10)
|
||||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
|
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
|
||||||
// IPv6 ULA (fc00::/7)
|
// IPv6 ULA (fc00::/7)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { WebSocketServer, WebSocket } from 'ws';
|
|||||||
import { db, canAccessTrip } from './db/database';
|
import { db, canAccessTrip } from './db/database';
|
||||||
import { consumeEphemeralToken } from './services/ephemeralTokens';
|
import { consumeEphemeralToken } from './services/ephemeralTokens';
|
||||||
import { User } from './types';
|
import { User } from './types';
|
||||||
import http from 'http';
|
import http from 'node:http';
|
||||||
|
|
||||||
interface NomadWebSocket extends WebSocket {
|
interface NomadWebSocket extends WebSocket {
|
||||||
isAlive: boolean;
|
isAlive: boolean;
|
||||||
@@ -48,7 +48,7 @@ function setupWebSocket(server: http.Server): void {
|
|||||||
|
|
||||||
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
wss!.clients.forEach((ws) => {
|
wss.clients.forEach((ws) => {
|
||||||
const nws = ws as NomadWebSocket;
|
const nws = ws as NomadWebSocket;
|
||||||
if (nws.isAlive === false) return nws.terminate();
|
if (nws.isAlive === false) return nws.terminate();
|
||||||
nws.isAlive = false;
|
nws.isAlive = false;
|
||||||
@@ -61,7 +61,7 @@ function setupWebSocket(server: http.Server): void {
|
|||||||
wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
|
wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
|
||||||
const nws = ws as NomadWebSocket;
|
const nws = ws as NomadWebSocket;
|
||||||
// Extract token from query param
|
// Extract token from query param
|
||||||
const url = new URL(req.url!, 'http://localhost');
|
const url = new URL(req.url, 'http://localhost');
|
||||||
const token = url.searchParams.get('token');
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -103,7 +103,7 @@ function setupWebSocket(server: http.Server): void {
|
|||||||
|
|
||||||
nws.on('message', (data) => {
|
nws.on('message', (data) => {
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
const rate = socketMsgCounts.get(nws)!;
|
const rate = socketMsgCounts.get(nws);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - rate.windowStart > WS_MSG_WINDOW) {
|
if (now - rate.windowStart > WS_MSG_WINDOW) {
|
||||||
rate.count = 1;
|
rate.count = 1;
|
||||||
@@ -129,14 +129,14 @@ function setupWebSocket(server: http.Server): void {
|
|||||||
if (msg.type === 'join' && msg.tripId) {
|
if (msg.type === 'join' && msg.tripId) {
|
||||||
const tripId = Number(msg.tripId);
|
const tripId = Number(msg.tripId);
|
||||||
// Verify the user has access to this trip
|
// Verify the user has access to this trip
|
||||||
if (!canAccessTrip(tripId, user!.id)) {
|
if (!canAccessTrip(tripId, user.id)) {
|
||||||
nws.send(JSON.stringify({ type: 'error', message: 'Access denied' }));
|
nws.send(JSON.stringify({ type: 'error', message: 'Access denied' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Add to room
|
// Add to room
|
||||||
if (!rooms.has(tripId)) rooms.set(tripId, new Set());
|
if (!rooms.has(tripId)) rooms.set(tripId, new Set());
|
||||||
rooms.get(tripId)!.add(nws);
|
rooms.get(tripId).add(nws);
|
||||||
socketRooms.get(nws)!.add(tripId);
|
socketRooms.get(nws).add(tripId);
|
||||||
nws.send(JSON.stringify({ type: 'joined', tripId }));
|
nws.send(JSON.stringify({ type: 'joined', tripId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ function broadcastToUser(userId: number, payload: Record<string, unknown>, exclu
|
|||||||
if (nws.readyState !== 1) continue;
|
if (nws.readyState !== 1) continue;
|
||||||
if (excludeNum && socketId.get(nws) === excludeNum) continue;
|
if (excludeNum && socketId.get(nws) === excludeNum) continue;
|
||||||
const user = socketUser.get(nws);
|
const user = socketUser.get(nws);
|
||||||
if (user && user.id === userId) {
|
if (user?.id === userId) {
|
||||||
nws.send(JSON.stringify(payload));
|
nws.send(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
server/tests/fixtures/small-image.jpg
vendored
Normal file
BIN
server/tests/fixtures/small-image.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 B |
11
server/tests/fixtures/test.gpx
vendored
Normal file
11
server/tests/fixtures/test.gpx
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="TREK Tests" xmlns="http://www.topografix.com/GPX/1/1">
|
||||||
|
<wpt lat="48.8566" lon="2.3522">
|
||||||
|
<name>Eiffel Tower</name>
|
||||||
|
<desc>Paris landmark</desc>
|
||||||
|
</wpt>
|
||||||
|
<wpt lat="48.8606" lon="2.3376">
|
||||||
|
<name>Louvre Museum</name>
|
||||||
|
<desc>Art museum</desc>
|
||||||
|
</wpt>
|
||||||
|
</gpx>
|
||||||
21
server/tests/fixtures/test.pdf
vendored
Normal file
21
server/tests/fixtures/test.pdf
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
1 0 obj
|
||||||
|
<< /Type /Catalog /Pages 2 0 R >>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000058 00000 n
|
||||||
|
0000000115 00000 n
|
||||||
|
trailer
|
||||||
|
<< /Size 4 /Root 1 0 R >>
|
||||||
|
startxref
|
||||||
|
190
|
||||||
|
%%EOF
|
||||||
34
server/tests/helpers/auth.ts
Normal file
34
server/tests/helpers/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Auth helpers for integration tests.
|
||||||
|
*
|
||||||
|
* Provides utilities to generate JWTs and authenticate supertest requests
|
||||||
|
* using the fixed test JWT_SECRET from TEST_CONFIG.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { TEST_CONFIG } from './test-db';
|
||||||
|
|
||||||
|
/** Signs a JWT for the given user ID using the test secret. */
|
||||||
|
export function generateToken(userId: number, extraClaims: Record<string, unknown> = {}): string {
|
||||||
|
return jwt.sign(
|
||||||
|
{ id: userId, ...extraClaims },
|
||||||
|
TEST_CONFIG.JWT_SECRET,
|
||||||
|
{ algorithm: 'HS256', expiresIn: '1h' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a cookie string suitable for supertest:
|
||||||
|
* request(app).get('/api/...').set('Cookie', authCookie(userId))
|
||||||
|
*/
|
||||||
|
export function authCookie(userId: number): string {
|
||||||
|
return `trek_session=${generateToken(userId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an Authorization header object suitable for supertest:
|
||||||
|
* request(app).get('/api/...').set(authHeader(userId))
|
||||||
|
*/
|
||||||
|
export function authHeader(userId: number): Record<string, string> {
|
||||||
|
return { Authorization: `Bearer ${generateToken(userId)}` };
|
||||||
|
}
|
||||||
287
server/tests/helpers/factories.ts
Normal file
287
server/tests/helpers/factories.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/**
|
||||||
|
* Test data factories.
|
||||||
|
* Each factory inserts a row into the provided in-memory DB and returns the created object.
|
||||||
|
* Passwords are stored as bcrypt hashes (cost factor 4 for speed in tests).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { encryptMfaSecret } from '../../src/services/mfaCrypto';
|
||||||
|
|
||||||
|
let _userSeq = 0;
|
||||||
|
let _tripSeq = 0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Users
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TestUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
password_hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(
|
||||||
|
db: Database.Database,
|
||||||
|
overrides: Partial<{ username: string; email: string; password: string; role: 'admin' | 'user' }> = {}
|
||||||
|
): { user: TestUser; password: string } {
|
||||||
|
_userSeq++;
|
||||||
|
const password = overrides.password ?? `TestPass${_userSeq}!`;
|
||||||
|
const email = overrides.email ?? `user${_userSeq}@test.example.com`;
|
||||||
|
const username = overrides.username ?? `testuser${_userSeq}`;
|
||||||
|
const role = overrides.role ?? 'user';
|
||||||
|
const hash = bcrypt.hashSync(password, 4); // cost 4 for test speed
|
||||||
|
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(username, email, hash, role);
|
||||||
|
|
||||||
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid) as TestUser;
|
||||||
|
return { user, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdmin(
|
||||||
|
db: Database.Database,
|
||||||
|
overrides: Partial<{ username: string; email: string; password: string }> = {}
|
||||||
|
): { user: TestUser; password: string } {
|
||||||
|
return createUser(db, { ...overrides, role: 'admin' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a user with MFA already enabled (directly in DB, bypasses rate-limited HTTP endpoints).
|
||||||
|
* Returns the user, password, and the TOTP secret so tests can generate valid codes.
|
||||||
|
*/
|
||||||
|
const KNOWN_MFA_SECRET = 'JBSWY3DPEHPK3PXP'; // fixed base32 secret for deterministic tests
|
||||||
|
export function createUserWithMfa(
|
||||||
|
db: Database.Database,
|
||||||
|
overrides: Partial<{ username: string; email: string; password: string; role: 'admin' | 'user' }> = {}
|
||||||
|
): { user: TestUser; password: string; totpSecret: string } {
|
||||||
|
const { user, password } = createUser(db, overrides);
|
||||||
|
const encryptedSecret = encryptMfaSecret(KNOWN_MFA_SECRET);
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?'
|
||||||
|
).run(encryptedSecret, user.id);
|
||||||
|
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as TestUser;
|
||||||
|
return { user: updated, password, totpSecret: KNOWN_MFA_SECRET };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trips
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TestTrip {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
title: string;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTrip(
|
||||||
|
db: Database.Database,
|
||||||
|
userId: number,
|
||||||
|
overrides: Partial<{ title: string; start_date: string; end_date: string; description: string }> = {}
|
||||||
|
): TestTrip {
|
||||||
|
_tripSeq++;
|
||||||
|
const title = overrides.title ?? `Test Trip ${_tripSeq}`;
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO trips (user_id, title, description, start_date, end_date) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(userId, title, overrides.description ?? null, overrides.start_date ?? null, overrides.end_date ?? null);
|
||||||
|
|
||||||
|
// Auto-generate days if dates are provided
|
||||||
|
if (overrides.start_date && overrides.end_date) {
|
||||||
|
const start = new Date(overrides.start_date);
|
||||||
|
const end = new Date(overrides.end_date);
|
||||||
|
const tripId = result.lastInsertRowid as number;
|
||||||
|
let dayNumber = 1;
|
||||||
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
const dateStr = d.toISOString().slice(0, 10);
|
||||||
|
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, dayNumber++, dateStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.prepare('SELECT * FROM trips WHERE id = ?').get(result.lastInsertRowid) as TestTrip;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Days
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TestDay {
|
||||||
|
id: number;
|
||||||
|
trip_id: number;
|
||||||
|
day_number: number;
|
||||||
|
date: string | null;
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDay(
|
||||||
|
db: Database.Database,
|
||||||
|
tripId: number,
|
||||||
|
overrides: Partial<{ date: string; title: string; day_number: number }> = {}
|
||||||
|
): TestDay {
|
||||||
|
// Find the next day_number for this trip if not provided
|
||||||
|
const maxDay = db.prepare('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||||
|
const dayNumber = overrides.day_number ?? (maxDay.max ?? 0) + 1;
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO days (trip_id, day_number, date, title) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(tripId, dayNumber, overrides.date ?? null, overrides.title ?? null);
|
||||||
|
return db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid) as TestDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Places
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TestPlace {
|
||||||
|
id: number;
|
||||||
|
trip_id: number;
|
||||||
|
name: string;
|
||||||
|
lat: number | null;
|
||||||
|
lng: number | null;
|
||||||
|
category_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlace(
|
||||||
|
db: Database.Database,
|
||||||
|
tripId: number,
|
||||||
|
overrides: Partial<{ name: string; lat: number; lng: number; category_id: number; description: string }> = {}
|
||||||
|
): TestPlace {
|
||||||
|
// Get first available category if none provided
|
||||||
|
const defaultCat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
|
||||||
|
const categoryId = overrides.category_id ?? defaultCat?.id ?? null;
|
||||||
|
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO places (trip_id, name, lat, lng, category_id, description) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
).run(
|
||||||
|
tripId,
|
||||||
|
overrides.name ?? 'Test Place',
|
||||||
|
overrides.lat ?? 48.8566,
|
||||||
|
overrides.lng ?? 2.3522,
|
||||||
|
categoryId,
|
||||||
|
overrides.description ?? null
|
||||||
|
);
|
||||||
|
return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid) as TestPlace;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trip Members
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function addTripMember(db: Database.Database, tripId: number, userId: number): void {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Budget Items
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TestBudgetItem {
|
||||||
|
id: number;
|
||||||
|
trip_id: number;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
total_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBudgetItem(
|
||||||
|
db: Database.Database,
|
||||||
|
tripId: number,
|
||||||
|
overrides: Partial<{ name: string; category: string; total_price: number }> = {}
|
||||||
|
): TestBudgetItem {
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO budget_items (trip_id, name, category, total_price) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(
|
||||||
|
tripId,
|
||||||
|
overrides.name ?? 'Test Budget Item',
|
||||||
|
overrides.category ?? 'Transport',
|
||||||
|
overrides.total_price ?? 100
|
||||||
|
);
|
||||||
|
return db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as TestBudgetItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Packing Items
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TestPackingItem {
|
||||||
|
id: number;
|
||||||
|
trip_id: number;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
checked: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPackingItem(
|
||||||
|
db: Database.Database,
|
||||||
|
tripId: number,
|
||||||
|
overrides: Partial<{ name: string; category: string }> = {}
|
||||||
|
): TestPackingItem {
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO packing_items (trip_id, name, category, checked) VALUES (?, ?, ?, 0)'
|
||||||
|
).run(tripId, overrides.name ?? 'Test Item', overrides.category ?? 'Clothing');
|
||||||
|
return db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid) as TestPackingItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reservations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TestReservation {
|
||||||
|
id: number;
|
||||||
|
trip_id: number;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReservation(
|
||||||
|
db: Database.Database,
|
||||||
|
tripId: number,
|
||||||
|
overrides: Partial<{ title: string; type: string; day_id: number }> = {}
|
||||||
|
): TestReservation {
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO reservations (trip_id, title, type, day_id) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(tripId, overrides.title ?? 'Test Reservation', overrides.type ?? 'flight', overrides.day_id ?? null);
|
||||||
|
return db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid) as TestReservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Invite Tokens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TestInviteToken {
|
||||||
|
id: number;
|
||||||
|
token: string;
|
||||||
|
max_uses: number | null;
|
||||||
|
used_count: number;
|
||||||
|
expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInviteToken(
|
||||||
|
db: Database.Database,
|
||||||
|
overrides: Partial<{ token: string; max_uses: number; expires_at: string; created_by: number }> = {}
|
||||||
|
): TestInviteToken {
|
||||||
|
const token = overrides.token ?? `test-invite-${Date.now()}`;
|
||||||
|
// created_by is required by the schema; use an existing admin or create one
|
||||||
|
let createdBy = overrides.created_by;
|
||||||
|
if (!createdBy) {
|
||||||
|
const admin = db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").get() as { id: number } | undefined;
|
||||||
|
if (admin) {
|
||||||
|
createdBy = admin.id;
|
||||||
|
} else {
|
||||||
|
const any = db.prepare('SELECT id FROM users LIMIT 1').get() as { id: number } | undefined;
|
||||||
|
if (any) {
|
||||||
|
createdBy = any.id;
|
||||||
|
} else {
|
||||||
|
const r = db.prepare("INSERT INTO users (username, email, password_hash, role) VALUES ('invite_creator', 'invite_creator@test.example.com', 'x', 'admin')").run();
|
||||||
|
createdBy = r.lastInsertRowid as number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES (?, ?, 0, ?, ?)'
|
||||||
|
).run(token, overrides.max_uses ?? 1, overrides.expires_at ?? null, createdBy);
|
||||||
|
return db.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(result.lastInsertRowid) as TestInviteToken;
|
||||||
|
}
|
||||||
193
server/tests/helpers/test-db.ts
Normal file
193
server/tests/helpers/test-db.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* In-memory SQLite test database helper.
|
||||||
|
*
|
||||||
|
* Usage in an integration test file:
|
||||||
|
*
|
||||||
|
* import { createTestDb, resetTestDb } from '../helpers/test-db';
|
||||||
|
* import { buildDbMock } from '../helpers/test-db';
|
||||||
|
*
|
||||||
|
* // Declare at module scope (before vi.mock so it's available in factory)
|
||||||
|
* const testDb = createTestDb();
|
||||||
|
*
|
||||||
|
* vi.mock('../../src/db/database', () => buildDbMock(testDb));
|
||||||
|
* vi.mock('../../src/config', () => TEST_CONFIG);
|
||||||
|
*
|
||||||
|
* beforeEach(() => resetTestDb(testDb));
|
||||||
|
* afterAll(() => testDb.close());
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
|
||||||
|
// Tables to clear on reset, ordered to avoid FK violations
|
||||||
|
const RESET_TABLES = [
|
||||||
|
'file_links',
|
||||||
|
'collab_poll_votes',
|
||||||
|
'collab_messages',
|
||||||
|
'collab_poll_options',
|
||||||
|
'collab_polls',
|
||||||
|
'collab_notes',
|
||||||
|
'day_notes',
|
||||||
|
'assignment_participants',
|
||||||
|
'day_assignments',
|
||||||
|
'packing_category_assignees',
|
||||||
|
'packing_bags',
|
||||||
|
'packing_items',
|
||||||
|
'budget_item_members',
|
||||||
|
'budget_items',
|
||||||
|
'trip_files',
|
||||||
|
'share_tokens',
|
||||||
|
'photos',
|
||||||
|
'reservations',
|
||||||
|
'day_accommodations',
|
||||||
|
'place_tags',
|
||||||
|
'places',
|
||||||
|
'days',
|
||||||
|
'trip_members',
|
||||||
|
'trips',
|
||||||
|
'vacay_entries',
|
||||||
|
'vacay_company_holidays',
|
||||||
|
'vacay_holiday_calendars',
|
||||||
|
'vacay_plan_members',
|
||||||
|
'vacay_years',
|
||||||
|
'vacay_plans',
|
||||||
|
'atlas_visited_countries',
|
||||||
|
'atlas_bucket_list',
|
||||||
|
'notifications',
|
||||||
|
'audit_log',
|
||||||
|
'user_settings',
|
||||||
|
'mcp_tokens',
|
||||||
|
'mcp_sessions',
|
||||||
|
'invite_tokens',
|
||||||
|
'tags',
|
||||||
|
'app_settings',
|
||||||
|
'users',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_CATEGORIES = [
|
||||||
|
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' },
|
||||||
|
{ name: 'Restaurant', color: '#ef4444', icon: '🍽️' },
|
||||||
|
{ name: 'Attraction', color: '#8b5cf6', icon: '🏛️' },
|
||||||
|
{ name: 'Shopping', color: '#f59e0b', icon: '🛍️' },
|
||||||
|
{ name: 'Transport', color: '#6b7280', icon: '🚌' },
|
||||||
|
{ name: 'Activity', color: '#10b981', icon: '🎯' },
|
||||||
|
{ name: 'Bar/Cafe', color: '#f97316', icon: '☕' },
|
||||||
|
{ name: 'Beach', color: '#06b6d4', icon: '🏖️' },
|
||||||
|
{ name: 'Nature', color: '#84cc16', icon: '🌿' },
|
||||||
|
{ name: 'Other', color: '#6366f1', icon: '📍' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_ADDONS = [
|
||||||
|
{ id: 'packing', name: 'Packing List', description: 'Pack your bags', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
||||||
|
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
||||||
|
{ id: 'documents', name: 'Documents', description: 'Manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
||||||
|
{ id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 },
|
||||||
|
{ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||||
|
{ id: 'mcp', name: 'MCP', description: 'AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
|
||||||
|
{ id: 'collab', name: 'Collab', description: 'Notes, polls, live chat', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function seedDefaults(db: Database.Database): void {
|
||||||
|
const insertCat = db.prepare('INSERT OR IGNORE INTO categories (name, color, icon) VALUES (?, ?, ?)');
|
||||||
|
for (const cat of DEFAULT_CATEGORIES) insertCat.run(cat.name, cat.color, cat.icon);
|
||||||
|
|
||||||
|
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||||
|
for (const a of DEFAULT_ADDONS) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a fresh in-memory SQLite database with the full schema and migrations applied.
|
||||||
|
* Default categories and addons are seeded. No users are created.
|
||||||
|
*/
|
||||||
|
export function createTestDb(): Database.Database {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
createTables(db);
|
||||||
|
runMigrations(db);
|
||||||
|
seedDefaults(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all user-generated data from the test DB and re-seeds defaults.
|
||||||
|
* Call in beforeEach() for test isolation within a file.
|
||||||
|
*/
|
||||||
|
export function resetTestDb(db: Database.Database): void {
|
||||||
|
db.exec('PRAGMA foreign_keys = OFF');
|
||||||
|
for (const table of RESET_TABLES) {
|
||||||
|
try { db.exec(`DELETE FROM "${table}"`); } catch { /* table may not exist in older schemas */ }
|
||||||
|
}
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
seedDefaults(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the mock factory for vi.mock('../../src/db/database', ...).
|
||||||
|
* The returned object mirrors the shape of database.ts exports.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const testDb = createTestDb();
|
||||||
|
* vi.mock('../../src/db/database', () => buildDbMock(testDb));
|
||||||
|
*/
|
||||||
|
export function buildDbMock(testDb: Database.Database) {
|
||||||
|
return {
|
||||||
|
db: testDb,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number | string) => {
|
||||||
|
interface PlaceRow {
|
||||||
|
id: number;
|
||||||
|
category_id: number | null;
|
||||||
|
category_name: string | null;
|
||||||
|
category_color: string | null;
|
||||||
|
category_icon: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
const place = testDb.prepare(`
|
||||||
|
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
|
FROM places p
|
||||||
|
LEFT JOIN categories c ON p.category_id = c.id
|
||||||
|
WHERE p.id = ?
|
||||||
|
`).get(placeId) as PlaceRow | undefined;
|
||||||
|
|
||||||
|
if (!place) return null;
|
||||||
|
|
||||||
|
const tags = testDb.prepare(`
|
||||||
|
SELECT t.* FROM tags t
|
||||||
|
JOIN place_tags pt ON t.id = pt.tag_id
|
||||||
|
WHERE pt.place_id = ?
|
||||||
|
`).all(placeId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...place,
|
||||||
|
category: place.category_id ? {
|
||||||
|
id: place.category_id,
|
||||||
|
name: place.category_name,
|
||||||
|
color: place.category_color,
|
||||||
|
icon: place.category_icon,
|
||||||
|
} : null,
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: number | string, userId: number) => {
|
||||||
|
return testDb.prepare(`
|
||||||
|
SELECT t.id, t.user_id FROM trips t
|
||||||
|
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||||
|
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||||
|
`).get(userId, tripId, userId);
|
||||||
|
},
|
||||||
|
isOwner: (tripId: number | string, userId: number) => {
|
||||||
|
return !!testDb.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed config mock — use with vi.mock('../../src/config', () => TEST_CONFIG) */
|
||||||
|
export const TEST_CONFIG = {
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
};
|
||||||
109
server/tests/helpers/ws-client.ts
Normal file
109
server/tests/helpers/ws-client.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket test client helper.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import http from 'http';
|
||||||
|
* import { setupWebSocket } from '../../src/websocket';
|
||||||
|
* import { WsTestClient, getWsToken } from '../helpers/ws-client';
|
||||||
|
*
|
||||||
|
* let server: http.Server;
|
||||||
|
* let client: WsTestClient;
|
||||||
|
*
|
||||||
|
* beforeAll(async () => {
|
||||||
|
* const app = createApp();
|
||||||
|
* server = http.createServer(app);
|
||||||
|
* setupWebSocket(server);
|
||||||
|
* await new Promise<void>(res => server.listen(0, res));
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* afterAll(() => server.close());
|
||||||
|
*
|
||||||
|
* it('connects', async () => {
|
||||||
|
* const addr = server.address() as AddressInfo;
|
||||||
|
* const token = await getWsToken(addr.port, userId);
|
||||||
|
* client = new WsTestClient(`ws://localhost:${addr.port}/ws?token=${token}`);
|
||||||
|
* const msg = await client.waitForMessage('welcome');
|
||||||
|
* expect(msg.type).toBe('welcome');
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
export interface WsMessage {
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WsTestClient {
|
||||||
|
private ws: WebSocket;
|
||||||
|
private messageQueue: WsMessage[] = [];
|
||||||
|
private waiters: Array<{ type: string; resolve: (msg: WsMessage) => void; reject: (err: Error) => void }> = [];
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.ws = new WebSocket(url);
|
||||||
|
this.ws.on('message', (data: WebSocket.RawData) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data.toString()) as WsMessage;
|
||||||
|
const waiterIdx = this.waiters.findIndex(w => w.type === msg.type || w.type === '*');
|
||||||
|
if (waiterIdx >= 0) {
|
||||||
|
const waiter = this.waiters.splice(waiterIdx, 1)[0];
|
||||||
|
waiter.resolve(msg);
|
||||||
|
} else {
|
||||||
|
this.messageQueue.push(msg);
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed messages */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for a message of the given type (or '*' for any). */
|
||||||
|
waitForMessage(type: string, timeoutMs = 5000): Promise<WsMessage> {
|
||||||
|
// Check if already in queue
|
||||||
|
const idx = this.messageQueue.findIndex(m => type === '*' || m.type === type);
|
||||||
|
if (idx >= 0) {
|
||||||
|
return Promise.resolve(this.messageQueue.splice(idx, 1)[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const waiterIdx = this.waiters.findIndex(w => w.resolve === resolve);
|
||||||
|
if (waiterIdx >= 0) this.waiters.splice(waiterIdx, 1);
|
||||||
|
reject(new Error(`Timed out waiting for WS message type="${type}" after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
this.waiters.push({
|
||||||
|
type,
|
||||||
|
resolve: (msg) => { clearTimeout(timer); resolve(msg); },
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a JSON message. */
|
||||||
|
send(msg: Record<string, unknown>): void {
|
||||||
|
this.ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the connection. */
|
||||||
|
close(): void {
|
||||||
|
this.ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for the connection to be open. */
|
||||||
|
waitForOpen(timeoutMs = 3000): Promise<void> {
|
||||||
|
if (this.ws.readyState === WebSocket.OPEN) return Promise.resolve();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('WS open timed out')), timeoutMs);
|
||||||
|
this.ws.once('open', () => { clearTimeout(timer); resolve(); });
|
||||||
|
this.ws.once('error', (err) => { clearTimeout(timer); reject(err); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for the connection to close. */
|
||||||
|
waitForClose(timeoutMs = 3000): Promise<number> {
|
||||||
|
if (this.ws.readyState === WebSocket.CLOSED) return Promise.resolve(1000);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('WS close timed out')), timeoutMs);
|
||||||
|
this.ws.once('close', (code) => { clearTimeout(timer); resolve(code); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
353
server/tests/integration/admin.test.ts
Normal file
353
server/tests/integration/admin.test.ts
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
/**
|
||||||
|
* Admin integration tests.
|
||||||
|
* Covers ADMIN-001 to ADMIN-022.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createAdmin, createInviteToken } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Access control
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Admin access control', () => {
|
||||||
|
it('ADMIN-022 — non-admin cannot access admin routes', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/admin/users')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN-022 — unauthenticated request returns 401', async () => {
|
||||||
|
const res = await request(app).get('/api/admin/users');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// User management
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Admin user management', () => {
|
||||||
|
it('ADMIN-001 — GET /admin/users lists all users', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
createUser(testDb);
|
||||||
|
createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/admin/users')
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.users.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN-002 — POST /admin/users creates a user', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/admin/users')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ username: 'newuser', email: 'newuser@example.com', password: 'Secure1234!', role: 'user' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.user.email).toBe('newuser@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN-003 — POST /admin/users with duplicate email returns 409', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
const { user: existing } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/admin/users')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ username: 'duplicate', email: existing.email, password: 'Secure1234!' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN-004 — PUT /admin/users/:id updates user', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/admin/users/${user.id}`)
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ username: 'updated_username' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user.username).toBe('updated_username');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN-005 — DELETE /admin/users/:id removes user', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/admin/users/${user.id}`)
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN-006 — admin cannot delete their own account', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/admin/users/${admin.id}`)
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// System stats
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('System stats', () => {
|
||||||
|
it('ADMIN-007 — GET /admin/stats returns system statistics', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/admin/stats')
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('totalUsers');
|
||||||
|
expect(res.body).toHaveProperty('totalTrips');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Permissions
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Permissions management', () => {
|
||||||
|
it('ADMIN-008 — GET /admin/permissions returns permission config', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/admin/permissions')
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('permissions');
|
||||||
|
expect(Array.isArray(res.body.permissions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN-008 — PUT /admin/permissions updates permissions', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const getRes = await request(app)
|
||||||
|
.get('/api/admin/permissions')
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
const currentPerms = getRes.body;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/admin/permissions')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ permissions: currentPerms });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN-008 — PUT /admin/permissions without object returns 400', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/admin/permissions')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ permissions: null });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Audit log
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Audit log', () => {
|
||||||
|
it('ADMIN-009 — GET /admin/audit-log returns log entries', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/admin/audit-log')
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.entries)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Addon management
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Addon management', () => {
|
||||||
|
it('ADMIN-011 — PUT /admin/addons/:id disables an addon', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/admin/addons/atlas')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ enabled: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN-012 — PUT /admin/addons/:id re-enables an addon', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.put('/api/admin/addons/atlas')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ enabled: false });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/admin/addons/atlas')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ enabled: true });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Invite tokens
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Invite token management', () => {
|
||||||
|
it('ADMIN-013 — POST /admin/invites creates an invite token', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/admin/invites')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ max_uses: 5 });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.invite.token).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN-014 — DELETE /admin/invites/:id removes invite', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
const invite = createInviteToken(testDb, { created_by: admin.id });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/admin/invites/${invite.id}`)
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Packing templates
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Packing templates', () => {
|
||||||
|
it('ADMIN-015 — POST /admin/packing-templates creates a template', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/admin/packing-templates')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ name: 'Beach Trip', description: 'Beach essentials' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.template.name).toBe('Beach Trip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN-016 — DELETE /admin/packing-templates/:id removes template', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
const create = await request(app)
|
||||||
|
.post('/api/admin/packing-templates')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ name: 'Temp Template' });
|
||||||
|
const templateId = create.body.template.id;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/admin/packing-templates/${templateId}`)
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Bag tracking
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Bag tracking', () => {
|
||||||
|
it('ADMIN-017 — PUT /admin/bag-tracking toggles bag tracking', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/admin/bag-tracking')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ enabled: true });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// JWT rotation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('JWT rotation', () => {
|
||||||
|
it('ADMIN-018 — POST /admin/rotate-jwt-secret rotates the JWT secret', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/admin/rotate-jwt-secret')
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
343
server/tests/integration/assignments.test.ts
Normal file
343
server/tests/integration/assignments.test.ts
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
/**
|
||||||
|
* Day Assignments integration tests.
|
||||||
|
* Covers ASSIGN-001 to ASSIGN-009.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: create a trip with a day and a place, return all three
|
||||||
|
function setupAssignmentFixtures(userId: number) {
|
||||||
|
const trip = createTrip(testDb, userId);
|
||||||
|
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'Test Place' });
|
||||||
|
return { trip, day, place };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Create assignment
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Create assignment', () => {
|
||||||
|
it('ASSIGN-001 — POST creates assignment linking place to day', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place.id });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
// The assignment has an embedded place object, not a top-level place_id
|
||||||
|
expect(res.body.assignment.place.id).toBe(place.id);
|
||||||
|
expect(res.body.assignment.day_id).toBe(day.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASSIGN-001 — POST with notes stores notes on assignment', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place.id, notes: 'Book table in advance' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.assignment.notes).toBe('Book table in advance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASSIGN-001 — POST with non-existent place returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { trip, day } = setupAssignmentFixtures(user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: 99999 });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASSIGN-001 — POST with non-existent day returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { trip, place } = setupAssignmentFixtures(user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/99999/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place.id });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASSIGN-006 — non-member cannot create assignment', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const { trip, day, place } = setupAssignmentFixtures(owner.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(other.id))
|
||||||
|
.send({ place_id: place.id });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASSIGN-006 — trip member can create assignment', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const { trip, day, place } = setupAssignmentFixtures(owner.id);
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(member.id))
|
||||||
|
.send({ place_id: place.id });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// List assignments
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('List assignments', () => {
|
||||||
|
it('ASSIGN-002 — GET /api/trips/:tripId/days/:dayId/assignments returns assignments for the day', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place.id });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.assignments).toHaveLength(1);
|
||||||
|
// Assignments have an embedded place object
|
||||||
|
expect(res.body.assignments[0].place.id).toBe(place.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASSIGN-002 — returns empty array when no assignments exist', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { trip, day } = setupAssignmentFixtures(user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.assignments).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASSIGN-006 — non-member cannot list assignments', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const { trip, day } = setupAssignmentFixtures(owner.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(other.id));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Delete assignment
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Delete assignment', () => {
|
||||||
|
it('ASSIGN-004 — DELETE removes assignment', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place.id });
|
||||||
|
const assignmentId = create.body.assignment.id;
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/days/${day.id}/assignments/${assignmentId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
const list = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(list.body.assignments).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASSIGN-004 — DELETE returns 404 for non-existent assignment', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { trip, day } = setupAssignmentFixtures(user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/days/${day.id}/assignments/99999`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Reorder assignments
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Reorder assignments', () => {
|
||||||
|
it('ASSIGN-007 — PUT /reorder reorders assignments within a day', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||||
|
const place1 = createPlace(testDb, trip.id, { name: 'Place A' });
|
||||||
|
const place2 = createPlace(testDb, trip.id, { name: 'Place B' });
|
||||||
|
|
||||||
|
const a1 = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place1.id });
|
||||||
|
const a2 = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place2.id });
|
||||||
|
|
||||||
|
const reorder = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/days/${day.id}/assignments/reorder`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ orderedIds: [a2.body.assignment.id, a1.body.assignment.id] });
|
||||||
|
expect(reorder.status).toBe(200);
|
||||||
|
expect(reorder.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Move assignment
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Move assignment', () => {
|
||||||
|
it('ASSIGN-008 — PUT /move transfers assignment to a different day', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day1 = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||||
|
const day2 = createDay(testDb, trip.id, { date: '2025-06-02' });
|
||||||
|
const place = createPlace(testDb, trip.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day1.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place.id });
|
||||||
|
const assignmentId = create.body.assignment.id;
|
||||||
|
|
||||||
|
const move = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/move`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ new_day_id: day2.id, order_index: 0 });
|
||||||
|
expect(move.status).toBe(200);
|
||||||
|
expect(move.body.assignment.day_id).toBe(day2.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Participants
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Assignment participants', () => {
|
||||||
|
it('ASSIGN-005 — PUT /participants updates participant list', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place.id });
|
||||||
|
const assignmentId = create.body.assignment.id;
|
||||||
|
|
||||||
|
const update = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/participants`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ user_ids: [user.id, member.id] });
|
||||||
|
expect(update.status).toBe(200);
|
||||||
|
|
||||||
|
const getParticipants = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/assignments/${assignmentId}/participants`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(getParticipants.status).toBe(200);
|
||||||
|
expect(getParticipants.body.participants).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASSIGN-009 — PUT /time updates assignment time fields', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place.id });
|
||||||
|
const assignmentId = create.body.assignment.id;
|
||||||
|
|
||||||
|
const update = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/time`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_time: '14:00', end_time: '16:00' });
|
||||||
|
expect(update.status).toBe(200);
|
||||||
|
// Time is embedded under assignment.place.place_time (COALESCEd from assignment_time)
|
||||||
|
expect(update.body.assignment.place.place_time).toBe('14:00');
|
||||||
|
expect(update.body.assignment.place.end_time).toBe('16:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
204
server/tests/integration/atlas.test.ts
Normal file
204
server/tests/integration/atlas.test.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Atlas integration tests.
|
||||||
|
* Covers ATLAS-001 to ATLAS-008.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Atlas stats', () => {
|
||||||
|
it('ATLAS-001 — GET /api/atlas/stats returns stats object', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/atlas/stats')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('countries');
|
||||||
|
expect(res.body).toHaveProperty('stats');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-002 — GET /api/atlas/country/:code returns places in country', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/atlas/country/FR')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.places)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Mark/unmark country', () => {
|
||||||
|
it('ATLAS-003 — POST /country/:code/mark marks country as visited', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/atlas/country/DE/mark')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify it appears in visited countries
|
||||||
|
const stats = await request(app)
|
||||||
|
.get('/api/addons/atlas/stats')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||||
|
expect(codes).toContain('DE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-004 — DELETE /country/:code/mark unmarks country', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/country/IT/mark')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/addons/atlas/country/IT/mark')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bucket list', () => {
|
||||||
|
it('ATLAS-005 — POST /bucket-list creates a bucket list item', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/atlas/bucket-list')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Machu Picchu', country_code: 'PE', lat: -13.1631, lng: -72.5450 });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.item.name).toBe('Machu Picchu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-005 — POST without name returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/atlas/bucket-list')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ country_code: 'JP' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-006 — GET /bucket-list returns items', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/bucket-list')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Santorini', country_code: 'GR' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/atlas/bucket-list')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-007 — PUT /bucket-list/:id updates item', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post('/api/addons/atlas/bucket-list')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Old Name' });
|
||||||
|
const id = create.body.item.id;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/addons/atlas/bucket-list/${id}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'New Name', notes: 'Updated' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.item.name).toBe('New Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-008 — DELETE /bucket-list/:id removes item', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post('/api/addons/atlas/bucket-list')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Tokyo' });
|
||||||
|
const id = create.body.item.id;
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/addons/atlas/bucket-list/${id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
|
||||||
|
const list = await request(app)
|
||||||
|
.get('/api/addons/atlas/bucket-list')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(list.body.items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-008 — DELETE non-existent item returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/addons/atlas/bucket-list/99999')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
480
server/tests/integration/auth.test.ts
Normal file
480
server/tests/integration/auth.test.ts
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
/**
|
||||||
|
* Authentication integration tests.
|
||||||
|
* Covers AUTH-001 to AUTH-022, AUTH-028 to AUTH-030.
|
||||||
|
* OIDC scenarios (AUTH-023 to AUTH-027) require a real IdP and are excluded.
|
||||||
|
* Rate limiting scenarios (AUTH-004, AUTH-018) are at the end of this file.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
import { authenticator } from 'otplib';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`
|
||||||
|
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
|
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||||
|
`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createAdmin, createUserWithMfa, createInviteToken } from '../helpers/factories';
|
||||||
|
import { authCookie, authHeader } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
// Reset rate limiter state between tests so they don't interfere
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Login
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Login', () => {
|
||||||
|
it('AUTH-001 — successful login returns 200, user object, and trek_session cookie', async () => {
|
||||||
|
const { user, password } = createUser(testDb);
|
||||||
|
const res = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user).toBeDefined();
|
||||||
|
expect(res.body.user.email).toBe(user.email);
|
||||||
|
expect(res.body.user.password_hash).toBeUndefined();
|
||||||
|
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
||||||
|
? res.headers['set-cookie']
|
||||||
|
: [res.headers['set-cookie']];
|
||||||
|
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-002 — wrong password returns 401 with generic message', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app).post('/api/auth/login').send({ email: user.email, password: 'WrongPass1!' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toContain('Invalid email or password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-003 — non-existent email returns 401 with same generic message (no user enumeration)', async () => {
|
||||||
|
const res = await request(app).post('/api/auth/login').send({ email: 'nobody@example.com', password: 'SomePass1!' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
// Must be same message as wrong-password to avoid email enumeration
|
||||||
|
expect(res.body.error).toContain('Invalid email or password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-013 — POST /api/auth/logout clears session cookie', async () => {
|
||||||
|
const res = await request(app).post('/api/auth/logout');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
||||||
|
? res.headers['set-cookie']
|
||||||
|
: (res.headers['set-cookie'] ? [res.headers['set-cookie']] : []);
|
||||||
|
const sessionCookie = cookies.find((c: string) => c.includes('trek_session'));
|
||||||
|
expect(sessionCookie).toBeDefined();
|
||||||
|
expect(sessionCookie).toMatch(/expires=Thu, 01 Jan 1970|Max-Age=0/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Registration
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Registration', () => {
|
||||||
|
it('AUTH-005 — first user registration creates admin role and returns 201 + cookie', async () => {
|
||||||
|
const res = await request(app).post('/api/auth/register').send({
|
||||||
|
username: 'firstadmin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
password: 'Str0ng!Pass',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.user.role).toBe('admin');
|
||||||
|
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
||||||
|
? res.headers['set-cookie']
|
||||||
|
: [res.headers['set-cookie']];
|
||||||
|
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-006 — registration with weak password is rejected', async () => {
|
||||||
|
const res = await request(app).post('/api/auth/register').send({
|
||||||
|
username: 'weakpwduser',
|
||||||
|
email: 'weak@example.com',
|
||||||
|
password: 'short',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-007 — registration with common password is rejected', async () => {
|
||||||
|
const res = await request(app).post('/api/auth/register').send({
|
||||||
|
username: 'commonpwd',
|
||||||
|
email: 'common@example.com',
|
||||||
|
password: 'Password1', // 'password1' is in the COMMON_PASSWORDS set
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/common/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-008 — registration with duplicate email returns 409', async () => {
|
||||||
|
createUser(testDb, { email: 'taken@example.com' });
|
||||||
|
const res = await request(app).post('/api/auth/register').send({
|
||||||
|
username: 'newuser',
|
||||||
|
email: 'taken@example.com',
|
||||||
|
password: 'Str0ng!Pass',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-009 — registration disabled by admin returns 403', async () => {
|
||||||
|
createUser(testDb);
|
||||||
|
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||||
|
const res = await request(app).post('/api/auth/register').send({
|
||||||
|
username: 'blocked',
|
||||||
|
email: 'blocked@example.com',
|
||||||
|
password: 'Str0ng!Pass',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/disabled/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-010 — registration with valid invite token succeeds even when registration disabled', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||||
|
const invite = createInviteToken(testDb, { max_uses: 1, created_by: admin.id });
|
||||||
|
|
||||||
|
const res = await request(app).post('/api/auth/register').send({
|
||||||
|
username: 'invited',
|
||||||
|
email: 'invited@example.com',
|
||||||
|
password: 'Str0ng!Pass',
|
||||||
|
invite_token: invite.token,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
|
||||||
|
const row = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number };
|
||||||
|
expect(row.used_count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-011 — GET /api/auth/invite/:token with expired token returns 410', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
const yesterday = new Date(Date.now() - 86_400_000).toISOString();
|
||||||
|
const invite = createInviteToken(testDb, { expires_at: yesterday, created_by: admin.id });
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/auth/invite/${invite.token}`);
|
||||||
|
expect(res.status).toBe(410);
|
||||||
|
expect(res.body.error).toMatch(/expired/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-012 — GET /api/auth/invite/:token with exhausted token returns 410', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
const invite = createInviteToken(testDb, { max_uses: 1, created_by: admin.id });
|
||||||
|
// Mark as exhausted
|
||||||
|
testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/auth/invite/${invite.token}`);
|
||||||
|
expect(res.status).toBe(410);
|
||||||
|
expect(res.body.error).toMatch(/fully used/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Session / Me
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Session', () => {
|
||||||
|
it('AUTH-014 — GET /api/auth/me without session returns 401 AUTH_REQUIRED', async () => {
|
||||||
|
const res = await request(app).get('/api/auth/me');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.code).toBe('AUTH_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-014 — GET /api/auth/me with valid cookie returns safe user object', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user.id).toBe(user.id);
|
||||||
|
expect(res.body.user.email).toBe(user.email);
|
||||||
|
expect(res.body.user.password_hash).toBeUndefined();
|
||||||
|
expect(res.body.user.mfa_secret).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-021 — user with must_change_password=1 sees the flag in their profile', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
testDb.prepare('UPDATE users SET must_change_password = 1 WHERE id = ?').run(user.id);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user.must_change_password).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// App Config (AUTH-028)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('App config', () => {
|
||||||
|
it('AUTH-028 — GET /api/auth/app-config returns expected flags', async () => {
|
||||||
|
const res = await request(app).get('/api/auth/app-config');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('allow_registration');
|
||||||
|
expect(res.body).toHaveProperty('oidc_configured');
|
||||||
|
expect(res.body).toHaveProperty('demo_mode');
|
||||||
|
expect(res.body).toHaveProperty('has_users');
|
||||||
|
expect(res.body).toHaveProperty('setup_complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-028 — allow_registration is false after admin disables it', async () => {
|
||||||
|
createUser(testDb);
|
||||||
|
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||||
|
const res = await request(app).get('/api/auth/app-config');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.allow_registration).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Demo Login (AUTH-022)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Demo login', () => {
|
||||||
|
it('AUTH-022 — POST /api/auth/demo-login without DEMO_MODE returns 404', async () => {
|
||||||
|
delete process.env.DEMO_MODE;
|
||||||
|
const res = await request(app).post('/api/auth/demo-login');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-022 — POST /api/auth/demo-login with DEMO_MODE and demo user returns 200 + cookie', async () => {
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@trek.app', 'x', 'user')"
|
||||||
|
).run();
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
try {
|
||||||
|
const res = await request(app).post('/api/auth/demo-login');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user.email).toBe('demo@trek.app');
|
||||||
|
} finally {
|
||||||
|
delete process.env.DEMO_MODE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// MFA (AUTH-015 to AUTH-019)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('MFA', () => {
|
||||||
|
it('AUTH-015 — POST /api/auth/mfa/setup returns secret and QR data URL', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/mfa/setup')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.secret).toBeDefined();
|
||||||
|
expect(res.body.otpauth_url).toContain('otpauth://');
|
||||||
|
expect(res.body.qr_data_url).toMatch(/^data:image/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-015 — POST /api/auth/mfa/enable with valid TOTP code enables MFA', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const setupRes = await request(app)
|
||||||
|
.post('/api/auth/mfa/setup')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(setupRes.status).toBe(200);
|
||||||
|
|
||||||
|
const enableRes = await request(app)
|
||||||
|
.post('/api/auth/mfa/enable')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ code: authenticator.generate(setupRes.body.secret) });
|
||||||
|
expect(enableRes.status).toBe(200);
|
||||||
|
expect(enableRes.body.mfa_enabled).toBe(true);
|
||||||
|
expect(Array.isArray(enableRes.body.backup_codes)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-016 — login with MFA-enabled account returns mfa_required + mfa_token', async () => {
|
||||||
|
const { user, password } = createUserWithMfa(testDb);
|
||||||
|
const loginRes = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ email: user.email, password });
|
||||||
|
expect(loginRes.status).toBe(200);
|
||||||
|
expect(loginRes.body.mfa_required).toBe(true);
|
||||||
|
expect(typeof loginRes.body.mfa_token).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-016 — POST /api/auth/mfa/verify-login with valid code completes login', async () => {
|
||||||
|
const { user, password, totpSecret } = createUserWithMfa(testDb);
|
||||||
|
|
||||||
|
const loginRes = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ email: user.email, password });
|
||||||
|
const { mfa_token } = loginRes.body;
|
||||||
|
|
||||||
|
const verifyRes = await request(app)
|
||||||
|
.post('/api/auth/mfa/verify-login')
|
||||||
|
.send({ mfa_token, code: authenticator.generate(totpSecret) });
|
||||||
|
expect(verifyRes.status).toBe(200);
|
||||||
|
expect(verifyRes.body.user).toBeDefined();
|
||||||
|
const cookies: string[] = Array.isArray(verifyRes.headers['set-cookie'])
|
||||||
|
? verifyRes.headers['set-cookie']
|
||||||
|
: [verifyRes.headers['set-cookie']];
|
||||||
|
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-017 — verify-login with invalid TOTP code returns 401', async () => {
|
||||||
|
const { user, password } = createUserWithMfa(testDb);
|
||||||
|
const loginRes = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ email: user.email, password });
|
||||||
|
|
||||||
|
const verifyRes = await request(app)
|
||||||
|
.post('/api/auth/mfa/verify-login')
|
||||||
|
.send({ mfa_token: loginRes.body.mfa_token, code: '000000' });
|
||||||
|
expect(verifyRes.status).toBe(401);
|
||||||
|
expect(verifyRes.body.error).toMatch(/invalid/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-019 — disable MFA with valid password and TOTP code', async () => {
|
||||||
|
const { user, password, totpSecret } = createUserWithMfa(testDb);
|
||||||
|
|
||||||
|
const disableRes = await request(app)
|
||||||
|
.post('/api/auth/mfa/disable')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ password, code: authenticator.generate(totpSecret) });
|
||||||
|
expect(disableRes.status).toBe(200);
|
||||||
|
expect(disableRes.body.mfa_enabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Forced MFA Policy (AUTH-020)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Forced MFA policy', () => {
|
||||||
|
it('AUTH-020 — non-MFA user is blocked (403 MFA_REQUIRED) when require_mfa is true', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||||
|
|
||||||
|
// mfaPolicy checks Authorization: Bearer header
|
||||||
|
const res = await request(app).get('/api/trips').set(authHeader(user.id));
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('MFA_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-020 — /api/auth/me and MFA setup endpoints are exempt from require_mfa', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||||
|
|
||||||
|
const meRes = await request(app).get('/api/auth/me').set(authHeader(user.id));
|
||||||
|
expect(meRes.status).toBe(200);
|
||||||
|
|
||||||
|
const setupRes = await request(app).post('/api/auth/mfa/setup').set(authHeader(user.id));
|
||||||
|
expect(setupRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-020 — MFA-enabled user passes through require_mfa policy', async () => {
|
||||||
|
const { user } = createUserWithMfa(testDb);
|
||||||
|
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/trips').set(authHeader(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Short-lived tokens (AUTH-029, AUTH-030)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Short-lived tokens', () => {
|
||||||
|
it('AUTH-029 — POST /api/auth/ws-token returns a single-use token', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/ws-token')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(typeof res.body.token).toBe('string');
|
||||||
|
expect(res.body.token.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-030 — POST /api/auth/resource-token returns a single-use token', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/resource-token')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ purpose: 'download' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(typeof res.body.token).toBe('string');
|
||||||
|
expect(res.body.token.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Rate limiting (AUTH-004, AUTH-018) — placed last
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Rate limiting', () => {
|
||||||
|
it('AUTH-004 — login endpoint rate-limits after 10 attempts from the same IP', async () => {
|
||||||
|
// beforeEach has cleared loginAttempts; we fill up exactly to the limit
|
||||||
|
let lastStatus = 0;
|
||||||
|
for (let i = 0; i <= 10; i++) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ email: 'ratelimit@example.com', password: 'wrong' });
|
||||||
|
lastStatus = res.status;
|
||||||
|
if (lastStatus === 429) break;
|
||||||
|
}
|
||||||
|
expect(lastStatus).toBe(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-018 — MFA verify-login endpoint rate-limits after 5 attempts', async () => {
|
||||||
|
let lastStatus = 0;
|
||||||
|
for (let i = 0; i <= 5; i++) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/mfa/verify-login')
|
||||||
|
.send({ mfa_token: 'badtoken', code: '000000' });
|
||||||
|
lastStatus = res.status;
|
||||||
|
if (lastStatus === 429) break;
|
||||||
|
}
|
||||||
|
expect(lastStatus).toBe(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
175
server/tests/integration/backup.test.ts
Normal file
175
server/tests/integration/backup.test.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Backup integration tests.
|
||||||
|
* Covers BACKUP-001 to BACKUP-008.
|
||||||
|
*
|
||||||
|
* Note: createBackup() is async and creates real files.
|
||||||
|
* These tests run in test env and may not have a full DB file to zip,
|
||||||
|
* but the service should handle gracefully.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock filesystem-dependent service functions to avoid real disk I/O in tests
|
||||||
|
vi.mock('../../src/services/backupService', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../../src/services/backupService')>('../../src/services/backupService');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createBackup: vi.fn().mockResolvedValue({
|
||||||
|
filename: 'backup-2026-04-03T06-00-00.zip',
|
||||||
|
size: 1024,
|
||||||
|
sizeText: '1.0 KB',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
updateAutoSettings: vi.fn().mockReturnValue({
|
||||||
|
enabled: false,
|
||||||
|
interval: 'daily',
|
||||||
|
keep_days: 7,
|
||||||
|
hour: 2,
|
||||||
|
day_of_week: 0,
|
||||||
|
day_of_month: 1,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createAdmin, createUser } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Backup access control', () => {
|
||||||
|
it('non-admin cannot access backup routes', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/backup/list')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Backup list', () => {
|
||||||
|
it('BACKUP-001 — GET /backup/list returns backups array', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/backup/list')
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.backups)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Backup creation', () => {
|
||||||
|
it('BACKUP-001 — POST /backup/create creates a backup', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/backup/create')
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.backup).toHaveProperty('filename');
|
||||||
|
expect(res.body.backup).toHaveProperty('size');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Auto-backup settings', () => {
|
||||||
|
it('BACKUP-008 — GET /backup/auto-settings returns current config', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/backup/auto-settings')
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('settings');
|
||||||
|
expect(res.body.settings).toHaveProperty('enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BACKUP-008 — PUT /backup/auto-settings updates settings', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/backup/auto-settings')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ enabled: false, interval: 'daily', keep_days: 7 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('settings');
|
||||||
|
expect(res.body.settings).toHaveProperty('enabled');
|
||||||
|
expect(res.body.settings).toHaveProperty('interval');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Backup security', () => {
|
||||||
|
it('BACKUP-007 — Download with path traversal filename is rejected', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/backup/download/../../etc/passwd')
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
// Express normalises the URL before routing; path traversal gets resolved
|
||||||
|
// to a path that matches no route → 404
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BACKUP-007 — Delete with path traversal filename is rejected', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/backup/../../../etc/passwd')
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
// Express normalises the URL, stripping traversal → no route match → 404
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
286
server/tests/integration/budget.test.ts
Normal file
286
server/tests/integration/budget.test.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* Budget Planner integration tests.
|
||||||
|
* Covers BUDGET-001 to BUDGET-010.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createTrip, createBudgetItem, addTripMember } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Create budget item
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Create budget item', () => {
|
||||||
|
it('BUDGET-001 — POST creates budget item', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/budget`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Flights', category: 'Transport', total_price: 500, currency: 'EUR' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.item.name).toBe('Flights');
|
||||||
|
expect(res.body.item.total_price).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BUDGET-001 — POST without name returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/budget`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ category: 'Transport', total_price: 200 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BUDGET-010 — non-member cannot create budget item', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/budget`)
|
||||||
|
.set('Cookie', authCookie(other.id))
|
||||||
|
.send({ name: 'Hotels', total_price: 300 });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// List budget items
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('List budget items', () => {
|
||||||
|
it('BUDGET-002 — GET /api/trips/:tripId/budget returns all items', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
createBudgetItem(testDb, trip.id, { name: 'Flight', total_price: 300 });
|
||||||
|
createBudgetItem(testDb, trip.id, { name: 'Hotel', total_price: 500 });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/budget`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BUDGET-002 — member can list budget items', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
createBudgetItem(testDb, trip.id, { name: 'Rental', total_price: 200 });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/budget`)
|
||||||
|
.set('Cookie', authCookie(member.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Update budget item
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Update budget item', () => {
|
||||||
|
it('BUDGET-003 — PUT updates budget item fields', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const item = createBudgetItem(testDb, trip.id, { name: 'Old Name', total_price: 100 });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/budget/${item.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'New Name', total_price: 250 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.item.name).toBe('New Name');
|
||||||
|
expect(res.body.item.total_price).toBe(250);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BUDGET-003 — PUT non-existent item returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/budget/99999`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Updated' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Delete budget item
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Delete budget item', () => {
|
||||||
|
it('BUDGET-004 — DELETE removes item', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const item = createBudgetItem(testDb, trip.id);
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/budget/${item.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
|
||||||
|
const list = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/budget`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(list.body.items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Members
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Budget item members', () => {
|
||||||
|
it('BUDGET-005 — PUT /members assigns members to budget item', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
const item = createBudgetItem(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ user_ids: [user.id, member.id] });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.members).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BUDGET-005 — PUT /members with non-array user_ids returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const item = createBudgetItem(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ user_ids: 'not-an-array' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BUDGET-006 — PUT /members/:userId/paid toggles paid status', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const item = createBudgetItem(testDb, trip.id);
|
||||||
|
|
||||||
|
// Assign user as member first
|
||||||
|
await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ user_ids: [user.id] });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ paid: true });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.member).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Summary & Settlement
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Budget summary and settlement', () => {
|
||||||
|
it('BUDGET-007 — GET /summary/per-person returns per-person breakdown', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/budget/summary/per-person`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.summary)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BUDGET-008 — GET /settlement returns settlement transactions', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('balances');
|
||||||
|
expect(res.body).toHaveProperty('flows');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BUDGET-009 — settlement with no payers returns empty transactions', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
// Item with no members/payers assigned
|
||||||
|
createBudgetItem(testDb, trip.id, { name: 'Train', total_price: 40 });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
543
server/tests/integration/collab.test.ts
Normal file
543
server/tests/integration/collab.test.ts
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
/**
|
||||||
|
* Collab (notes, polls, messages, reactions) integration tests.
|
||||||
|
* Covers COLLAB-001 to COLLAB-027.
|
||||||
|
*
|
||||||
|
* Note: File upload to collab notes (COLLAB-005/006/007) requires physical file I/O.
|
||||||
|
* Link preview (COLLAB-025/026) would need fetch mocking — skipped here.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||||
|
|
||||||
|
// Ensure uploads/files dir exists for collab file uploads
|
||||||
|
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Collab Notes
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Collab notes', () => {
|
||||||
|
it('COLLAB-001 — POST /collab/notes creates a note', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Packing Ideas', content: 'Bring sunscreen', category: 'Planning' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.note.title).toBe('Packing Ideas');
|
||||||
|
expect(res.body.note.content).toBe('Bring sunscreen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-001 — POST without title returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ content: 'No title' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-001 — non-member cannot create collab note', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||||
|
.set('Cookie', authCookie(other.id))
|
||||||
|
.send({ title: 'Sneaky note' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-002 — GET /collab/notes returns all notes', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Note A' });
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Note B' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/collab/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.notes).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-003 — PUT /collab/notes/:id updates a note', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Old Title', content: 'Old content' });
|
||||||
|
const noteId = create.body.note.id;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/collab/notes/${noteId}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'New Title', content: 'New content', pinned: true });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.note.title).toBe('New Title');
|
||||||
|
expect(res.body.note.pinned).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-003 — PUT non-existent note returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/collab/notes/99999`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Updated' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-004 — DELETE /collab/notes/:id removes note', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'To Delete' });
|
||||||
|
const noteId = create.body.note.id;
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/collab/notes/${noteId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
|
||||||
|
const list = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/collab/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(list.body.notes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-005 — POST /collab/notes/:id/files uploads a file to a note', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Note with file' });
|
||||||
|
const noteId = create.body.note.id;
|
||||||
|
|
||||||
|
const upload = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.attach('file', FIXTURE_PDF);
|
||||||
|
expect(upload.status).toBe(201);
|
||||||
|
expect(upload.body.file).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-006 — uploading blocked extension to note is rejected', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Note' });
|
||||||
|
const noteId = create.body.note.id;
|
||||||
|
|
||||||
|
// Create a temp .svg file
|
||||||
|
const svgPath = path.join(uploadsDir, 'collab_blocked.svg');
|
||||||
|
fs.writeFileSync(svgPath, '<svg></svg>');
|
||||||
|
try {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.attach('file', svgPath);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
} finally {
|
||||||
|
if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-007 — DELETE /collab/notes/:noteId/files/:fileId removes file from note', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Note with file' });
|
||||||
|
const noteId = create.body.note.id;
|
||||||
|
|
||||||
|
const upload = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.attach('file', FIXTURE_PDF);
|
||||||
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/collab/notes/${noteId}/files/${fileId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Polls
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Polls', () => {
|
||||||
|
it('COLLAB-008 — POST /collab/polls creates a poll', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ question: 'Where to eat?', options: ['Pizza', 'Sushi', 'Tacos'] });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.poll.question).toBe('Where to eat?');
|
||||||
|
expect(res.body.poll.options).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-008 — POST without question returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ options: ['A', 'B'] });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-009 — GET /collab/polls returns polls', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ question: 'Beach or mountains?', options: ['Beach', 'Mountains'] });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/collab/polls`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.polls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-010 — POST /collab/polls/:id/vote casts a vote', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ question: 'Restaurant?', options: ['Italian', 'French'] });
|
||||||
|
const pollId = create.body.poll.id;
|
||||||
|
|
||||||
|
const vote = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/polls/${pollId}/vote`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ option_index: 0 });
|
||||||
|
expect(vote.status).toBe(200);
|
||||||
|
expect(vote.body.poll).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-011 — PUT /collab/polls/:id/close closes a poll', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ question: 'Hotel?', options: ['Budget', 'Luxury'] });
|
||||||
|
const pollId = create.body.poll.id;
|
||||||
|
|
||||||
|
const close = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/collab/polls/${pollId}/close`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(close.status).toBe(200);
|
||||||
|
expect(close.body.poll.is_closed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-012 — cannot vote on closed poll', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ question: 'Closed?', options: ['Yes', 'No'] });
|
||||||
|
const pollId = create.body.poll.id;
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/collab/polls/${pollId}/close`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const vote = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/polls/${pollId}/vote`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ option_index: 0 });
|
||||||
|
expect(vote.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-013 — DELETE /collab/polls/:id removes poll', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ question: 'Delete me?', options: ['Yes', 'No'] });
|
||||||
|
const pollId = create.body.poll.id;
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/collab/polls/${pollId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Messages
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Messages', () => {
|
||||||
|
it('COLLAB-014 — POST /collab/messages sends a message', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'Hello, team!' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.message.text).toBe('Hello, team!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-014 — POST without text returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: '' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-014 — non-member cannot send message', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(other.id))
|
||||||
|
.send({ text: 'Unauthorized' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-015 — GET /collab/messages returns messages in order', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'First message' });
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'Second message' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.messages.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-016 — POST /collab/messages with reply_to links reply', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const parent = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'Original' });
|
||||||
|
const parentId = parent.body.message.id;
|
||||||
|
|
||||||
|
const reply = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'Reply here', reply_to: parentId });
|
||||||
|
expect(reply.status).toBe(201);
|
||||||
|
expect(reply.body.message.reply_to).toBe(parentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-017 — DELETE /collab/messages/:id removes own message', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const msg = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'Delete me' });
|
||||||
|
const msgId = msg.body.message.id;
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/collab/messages/${msgId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-017 — cannot delete another user\'s message', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
const msg = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(owner.id))
|
||||||
|
.send({ text: 'Owner message' });
|
||||||
|
const msgId = msg.body.message.id;
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/collab/messages/${msgId}`)
|
||||||
|
.set('Cookie', authCookie(member.id));
|
||||||
|
expect(del.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Reactions
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Message reactions', () => {
|
||||||
|
it('COLLAB-018 — POST /collab/messages/:id/react adds a reaction', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const msg = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'React to me' });
|
||||||
|
const msgId = msg.body.message.id;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages/${msgId}/react`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ emoji: '👍' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.reactions).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('COLLAB-018 — POST react without emoji returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const msg = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'Test' });
|
||||||
|
const msgId = msg.body.message.id;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages/${msgId}/react`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Long text validation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Collab validation', () => {
|
||||||
|
it('COLLAB-018 — message text exceeding 5000 chars is rejected', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'A'.repeat(5001) });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
235
server/tests/integration/dayNotes.test.ts
Normal file
235
server/tests/integration/dayNotes.test.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Day Notes integration tests.
|
||||||
|
* Covers NOTE-001 to NOTE-006.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createTrip, createDay, addTripMember } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Create day note
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Create day note', () => {
|
||||||
|
it('NOTE-001 — POST creates a day note', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'Remember to book tickets', time: '09:00' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.note.text).toBe('Remember to book tickets');
|
||||||
|
expect(res.body.note.time).toBe('09:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTE-001 — POST without text returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ time: '10:00' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTE-002 — text exceeding 500 characters is rejected', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'A'.repeat(501) });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTE-001 — POST on non-existent day returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/99999/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'This should fail' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// List day notes
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('List day notes', () => {
|
||||||
|
it('NOTE-003 — GET returns notes for a day', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'Note A' });
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'Note B' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.notes).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTE-006 — non-member cannot list notes', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||||
|
.set('Cookie', authCookie(other.id));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Update day note
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Update day note', () => {
|
||||||
|
it('NOTE-004 — PUT updates a note', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'Old text' });
|
||||||
|
const noteId = create.body.note.id;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/days/${day.id}/notes/${noteId}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'New text', icon: '🎯' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.note.text).toBe('New text');
|
||||||
|
expect(res.body.note.icon).toBe('🎯');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTE-004 — PUT on non-existent note returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/days/${day.id}/notes/99999`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'Updated' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Delete day note
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Delete day note', () => {
|
||||||
|
it('NOTE-005 — DELETE removes note', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ text: 'To delete' });
|
||||||
|
const noteId = create.body.note.id;
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/days/${day.id}/notes/${noteId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
|
||||||
|
const list = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(list.body.notes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTE-005 — DELETE non-existent note returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/days/${day.id}/notes/99999`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
465
server/tests/integration/days.test.ts
Normal file
465
server/tests/integration/days.test.ts
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
/**
|
||||||
|
* Days & Accommodations API integration tests.
|
||||||
|
* Covers DAY-001 through DAY-006 and ACCOM-001 through ACCOM-003.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// In-memory DB — schema applied in beforeAll after mocks register
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||||
|
beforeEach(() => { resetTestDb(testDb); loginAttempts.clear(); mfaAttempts.clear(); });
|
||||||
|
afterAll(() => { testDb.close(); });
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// List days (DAY-001, DAY-002)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('List days', () => {
|
||||||
|
it('DAY-001 — GET /api/trips/:tripId/days returns days for a trip the user can access', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Paris Trip', start_date: '2026-06-01', end_date: '2026-06-03' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/days`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.days).toBeDefined();
|
||||||
|
expect(Array.isArray(res.body.days)).toBe(true);
|
||||||
|
expect(res.body.days).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAY-001 — Member can list days for a shared trip', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip', start_date: '2026-07-01', end_date: '2026-07-02' });
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/days`)
|
||||||
|
.set('Cookie', authCookie(member.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.days).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAY-002 — Non-member cannot list days (404)', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: stranger } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/days`)
|
||||||
|
.set('Cookie', authCookie(stranger.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAY-002 — Unauthenticated request returns 401', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/trips/${trip.id}/days`);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Create day (DAY-006)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Create day', () => {
|
||||||
|
it('DAY-006 — POST /api/trips/:tripId/days creates a standalone day with no date', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Open Trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ notes: 'A free day' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.day).toBeDefined();
|
||||||
|
expect(res.body.day.trip_id).toBe(trip.id);
|
||||||
|
expect(res.body.day.date).toBeNull();
|
||||||
|
expect(res.body.day.notes).toBe('A free day');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAY-006 — POST /api/trips/:tripId/days creates a day with a date', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Dated Trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ date: '2026-08-15' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.day.date).toBe('2026-08-15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAY-006 — Non-member cannot create a day (404)', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: stranger } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Private' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/days`)
|
||||||
|
.set('Cookie', authCookie(stranger.id))
|
||||||
|
.send({ notes: 'Infiltration' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Update day (DAY-003, DAY-004)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Update day', () => {
|
||||||
|
it('DAY-003 — PUT /api/trips/:tripId/days/:dayId updates the day title', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'My Trip' });
|
||||||
|
const day = createDay(testDb, trip.id, { title: 'Old Title' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/days/${day.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'New Title' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.day).toBeDefined();
|
||||||
|
expect(res.body.day.title).toBe('New Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAY-004 — PUT /api/trips/:tripId/days/:dayId updates the day notes', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'My Trip' });
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/days/${day.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ notes: 'Visit the Louvre' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.day.notes).toBe('Visit the Louvre');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAY-003 — PUT returns 404 for a day that does not belong to the trip', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'My Trip' });
|
||||||
|
createDay(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/days/999999`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Ghost' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAY-003 — Non-member cannot update a day (404)', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: stranger } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Private' });
|
||||||
|
const day = createDay(testDb, trip.id, { title: 'Original' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/days/${day.id}`)
|
||||||
|
.set('Cookie', authCookie(stranger.id))
|
||||||
|
.send({ title: 'Hacked' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Reorder days (DAY-005)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Reorder days', () => {
|
||||||
|
it('DAY-005 — Reorder: GET days returns them in day_number order', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
// Create trip with 3 days auto-generated
|
||||||
|
const trip = createTrip(testDb, user.id, {
|
||||||
|
title: 'Trip',
|
||||||
|
start_date: '2026-09-01',
|
||||||
|
end_date: '2026-09-03',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/days`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.days).toHaveLength(3);
|
||||||
|
// Days should be ordered by day_number ascending (the service sorts by day_number ASC)
|
||||||
|
expect(res.body.days[0].date).toBe('2026-09-01');
|
||||||
|
expect(res.body.days[2].date).toBe('2026-09-03');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Delete day
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Delete day', () => {
|
||||||
|
it('DELETE /api/trips/:tripId/days/:dayId removes the day', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/days/${day.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
const deleted = testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id);
|
||||||
|
expect(deleted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/trips/:tripId/days/:dayId returns 404 for unknown day', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/days/999999`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Accommodations (ACCOM-001, ACCOM-002, ACCOM-003)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Accommodations', () => {
|
||||||
|
it('ACCOM-001 — POST /api/trips/:tripId/accommodations creates an accommodation', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||||
|
const day1 = createDay(testDb, trip.id, { date: '2026-10-01' });
|
||||||
|
const day2 = createDay(testDb, trip.id, { date: '2026-10-03' });
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/accommodations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({
|
||||||
|
place_id: place.id,
|
||||||
|
start_day_id: day1.id,
|
||||||
|
end_day_id: day2.id,
|
||||||
|
check_in: '15:00',
|
||||||
|
check_out: '11:00',
|
||||||
|
confirmation: 'ABC123',
|
||||||
|
notes: 'Breakfast included',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.accommodation).toBeDefined();
|
||||||
|
expect(res.body.accommodation.place_id).toBe(place.id);
|
||||||
|
expect(res.body.accommodation.start_day_id).toBe(day1.id);
|
||||||
|
expect(res.body.accommodation.end_day_id).toBe(day2.id);
|
||||||
|
expect(res.body.accommodation.confirmation).toBe('ABC123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ACCOM-001 — POST missing required fields returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/accommodations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ notes: 'no ids' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/required/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ACCOM-001 — POST with invalid place_id returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/accommodations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: 999999, start_day_id: day.id, end_day_id: day.id });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ACCOM-002 — GET /api/trips/:tripId/accommodations returns accommodations for the trip', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||||
|
const day1 = createDay(testDb, trip.id, { date: '2026-11-01' });
|
||||||
|
const day2 = createDay(testDb, trip.id, { date: '2026-11-03' });
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'Boutique Inn' });
|
||||||
|
|
||||||
|
// Seed accommodation directly
|
||||||
|
testDb.prepare(
|
||||||
|
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(trip.id, place.id, day1.id, day2.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/accommodations`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.accommodations).toBeDefined();
|
||||||
|
expect(Array.isArray(res.body.accommodations)).toBe(true);
|
||||||
|
expect(res.body.accommodations).toHaveLength(1);
|
||||||
|
expect(res.body.accommodations[0].place_name).toBe('Boutique Inn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ACCOM-002 — Non-member cannot get accommodations (404)', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: stranger } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/accommodations`)
|
||||||
|
.set('Cookie', authCookie(stranger.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ACCOM-003 — DELETE /api/trips/:tripId/accommodations/:id removes accommodation', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||||
|
const day1 = createDay(testDb, trip.id, { date: '2026-12-01' });
|
||||||
|
const day2 = createDay(testDb, trip.id, { date: '2026-12-03' });
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'Budget Hostel' });
|
||||||
|
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/accommodations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id });
|
||||||
|
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
const accommodationId = createRes.body.accommodation.id;
|
||||||
|
|
||||||
|
const deleteRes = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(deleteRes.status).toBe(200);
|
||||||
|
expect(deleteRes.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify removed from DB
|
||||||
|
const row = testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accommodationId);
|
||||||
|
expect(row).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ACCOM-003 — DELETE non-existent accommodation returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/accommodations/999999`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ACCOM-001 — Creating accommodation also creates a linked reservation', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||||
|
const day1 = createDay(testDb, trip.id, { date: '2026-10-10' });
|
||||||
|
const day2 = createDay(testDb, trip.id, { date: '2026-10-12' });
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'Luxury Resort' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/accommodations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id, confirmation: 'CONF-XYZ' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
|
||||||
|
// Linked reservation should exist
|
||||||
|
const reservation = testDb.prepare(
|
||||||
|
'SELECT * FROM reservations WHERE accommodation_id = ?'
|
||||||
|
).get(res.body.accommodation.id) as any;
|
||||||
|
expect(reservation).toBeDefined();
|
||||||
|
expect(reservation.type).toBe('hotel');
|
||||||
|
expect(reservation.confirmation_number).toBe('CONF-XYZ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ACCOM-003 — Deleting accommodation also removes the linked reservation', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||||
|
const day1 = createDay(testDb, trip.id, { date: '2026-10-15' });
|
||||||
|
const day2 = createDay(testDb, trip.id, { date: '2026-10-17' });
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'Mountain Lodge' });
|
||||||
|
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/accommodations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id });
|
||||||
|
|
||||||
|
const accommodationId = createRes.body.accommodation.id;
|
||||||
|
const reservationBefore = testDb.prepare(
|
||||||
|
'SELECT id FROM reservations WHERE accommodation_id = ?'
|
||||||
|
).get(accommodationId) as any;
|
||||||
|
expect(reservationBefore).toBeDefined();
|
||||||
|
|
||||||
|
const deleteRes = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(deleteRes.status).toBe(200);
|
||||||
|
|
||||||
|
const reservationAfter = testDb.prepare(
|
||||||
|
'SELECT id FROM reservations WHERE id = ?'
|
||||||
|
).get(reservationBefore.id);
|
||||||
|
expect(reservationAfter).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
382
server/tests/integration/files.test.ts
Normal file
382
server/tests/integration/files.test.ts
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
/**
|
||||||
|
* Trip Files integration tests.
|
||||||
|
* Covers FILE-001 to FILE-021.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - Tests use fixture files from tests/fixtures/
|
||||||
|
* - File uploads create real files in uploads/files/ — tests clean up after themselves where possible
|
||||||
|
* - FILE-009 (ephemeral token download) is covered via the /api/auth/resource-token endpoint
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createTrip, createReservation, addTripMember } from '../helpers/factories';
|
||||||
|
import { authCookie, generateToken } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||||
|
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||||
|
|
||||||
|
// Ensure uploads/files dir exists
|
||||||
|
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
// Seed allowed_file_types to include common types (wildcard)
|
||||||
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
// Re-seed allowed_file_types after reset
|
||||||
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to upload a file and return the file object
|
||||||
|
async function uploadFile(tripId: number, userId: number, fixturePath = FIXTURE_PDF) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${tripId}/files`)
|
||||||
|
.set('Cookie', authCookie(userId))
|
||||||
|
.attach('file', fixturePath);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Upload file
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Upload file', () => {
|
||||||
|
it('FILE-001 — POST uploads a file and returns file metadata', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.file).toBeDefined();
|
||||||
|
expect(res.body.file.id).toBeDefined();
|
||||||
|
expect(res.body.file.filename).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FILE-002 — uploading a blocked extension (.svg) is rejected', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
// Create a temp .svg file
|
||||||
|
const svgPath = path.join(uploadsDir, 'test_blocked.svg');
|
||||||
|
fs.writeFileSync(svgPath, '<svg></svg>');
|
||||||
|
try {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/files`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.attach('file', svgPath);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
} finally {
|
||||||
|
if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FILE-021 — non-member cannot upload file', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/files`)
|
||||||
|
.set('Cookie', authCookie(other.id))
|
||||||
|
.attach('file', FIXTURE_PDF);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// List files
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('List files', () => {
|
||||||
|
it('FILE-006 — GET returns all non-trashed files', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
await uploadFile(trip.id, user.id, FIXTURE_IMG);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/files`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.files.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FILE-007 — GET ?trash=true returns only trashed files', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
|
// Soft-delete it
|
||||||
|
await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/files/${fileId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const trash = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/files?trash=true`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(trash.status).toBe(200);
|
||||||
|
const trashIds = (trash.body.files as any[]).map((f: any) => f.id);
|
||||||
|
expect(trashIds).toContain(fileId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Star / unstar
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Star/unstar file', () => {
|
||||||
|
it('FILE-011 — PATCH /:id/star toggles starred status', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.patch(`/api/trips/${trip.id}/files/${fileId}/star`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.file.starred).toBe(1);
|
||||||
|
|
||||||
|
// Toggle back
|
||||||
|
const res2 = await request(app)
|
||||||
|
.patch(`/api/trips/${trip.id}/files/${fileId}/star`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res2.body.file.starred).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Soft delete / restore / permanent delete
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Soft delete, restore, permanent delete', () => {
|
||||||
|
it('FILE-012 — DELETE moves file to trash', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/files/${fileId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Should not appear in normal list
|
||||||
|
const list = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/files`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
const ids = (list.body.files as any[]).map((f: any) => f.id);
|
||||||
|
expect(ids).not.toContain(fileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FILE-013 — POST /:id/restore restores from trash', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/files/${fileId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const restore = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/files/${fileId}/restore`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(restore.status).toBe(200);
|
||||||
|
expect(restore.body.file.id).toBe(fileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FILE-014 — DELETE /:id/permanent permanently deletes from trash', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/files/${fileId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const perm = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/files/${fileId}/permanent`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(perm.status).toBe(200);
|
||||||
|
expect(perm.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FILE-015 — DELETE /:id/permanent on non-trashed file returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
|
// Not trashed — should 404
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/files/${fileId}/permanent`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FILE-016 — DELETE /trash/empty empties all trash', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const f1 = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
const f2 = await uploadFile(trip.id, user.id, FIXTURE_IMG);
|
||||||
|
|
||||||
|
await request(app).delete(`/api/trips/${trip.id}/files/${f1.body.file.id}`).set('Cookie', authCookie(user.id));
|
||||||
|
await request(app).delete(`/api/trips/${trip.id}/files/${f2.body.file.id}`).set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const empty = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/files/trash/empty`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(empty.status).toBe(200);
|
||||||
|
|
||||||
|
const trash = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/files?trash=true`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(trash.body.files).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Update file metadata
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Update file metadata', () => {
|
||||||
|
it('FILE-017 — PUT updates description', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/files/${fileId}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ description: 'My important document' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.file.description).toBe('My important document');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// File links
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('File links', () => {
|
||||||
|
it('FILE-018/019/020 — link file to reservation, list links, unlink', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const resv = createReservation(testDb, trip.id, { title: 'My Flight', type: 'flight' });
|
||||||
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
|
// Link (POST /:id/link)
|
||||||
|
const link = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/files/${fileId}/link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ reservation_id: resv.id });
|
||||||
|
expect(link.status).toBe(200);
|
||||||
|
expect(link.body.success).toBe(true);
|
||||||
|
|
||||||
|
// List links (GET /:id/links)
|
||||||
|
const links = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/files/${fileId}/links`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(links.status).toBe(200);
|
||||||
|
expect(links.body.links.some((l: any) => l.reservation_id === resv.id)).toBe(true);
|
||||||
|
|
||||||
|
// Unlink (DELETE /:id/link/:linkId — use the link id from the list)
|
||||||
|
const linkId = links.body.links.find((l: any) => l.reservation_id === resv.id)?.id;
|
||||||
|
expect(linkId).toBeDefined();
|
||||||
|
const unlink = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/files/${fileId}/link/${linkId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(unlink.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Download
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('File download', () => {
|
||||||
|
it('FILE-010 — GET /:id/download without auth returns 401', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/files/${fileId}/download`);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FILE-008 — GET /:id/download with Bearer JWT downloads or 404s (no physical file in tests)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
|
// authenticateDownload accepts a signed JWT as Bearer token
|
||||||
|
const token = generateToken(user.id);
|
||||||
|
|
||||||
|
const dl = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
// multer stores the file to disk during uploadFile — physical file exists
|
||||||
|
expect(dl.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
122
server/tests/integration/health.test.ts
Normal file
122
server/tests/integration/health.test.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Basic smoke test to validate the integration test DB mock pattern.
|
||||||
|
* Tests MISC-001 — Health check endpoint.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step 1: Create a bare in-memory DB instance via vi.hoisted() so it exists
|
||||||
|
// before the mock factory below runs. Schema setup happens in beforeAll
|
||||||
|
// (after mocks are registered, so config is mocked when migrations run).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`
|
||||||
|
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
|
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||||
|
`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step 2: Register mocks BEFORE app is imported (these are hoisted by Vitest)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step 3: Import app AFTER mocks (Vitest hoisting ensures mocks are ready first)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
// Schema setup runs here — config is mocked so migrations work correctly
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Health check', () => {
|
||||||
|
it('MISC-001 — GET /api/health returns 200 with status ok', async () => {
|
||||||
|
const res = await request(app).get('/api/health');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Basic auth', () => {
|
||||||
|
it('AUTH-014 — GET /api/auth/me without session returns 401', async () => {
|
||||||
|
const res = await request(app).get('/api/auth/me');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.code).toBe('AUTH_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-001 — POST /api/auth/login with valid credentials returns 200 + cookie', async () => {
|
||||||
|
const { user, password } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ email: user.email, password });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user).toMatchObject({ id: user.id, email: user.email });
|
||||||
|
expect(res.headers['set-cookie']).toBeDefined();
|
||||||
|
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
||||||
|
? res.headers['set-cookie']
|
||||||
|
: [res.headers['set-cookie']];
|
||||||
|
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-014 — authenticated GET /api/auth/me returns user object', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/auth/me')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user.id).toBe(user.id);
|
||||||
|
expect(res.body.user.email).toBe(user.email);
|
||||||
|
});
|
||||||
|
});
|
||||||
147
server/tests/integration/immich.test.ts
Normal file
147
server/tests/integration/immich.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Immich integration tests.
|
||||||
|
* Covers IMMICH-001 to IMMICH-015 (settings, SSRF protection, connection test).
|
||||||
|
*
|
||||||
|
* External Immich API calls are not made — tests focus on settings persistence
|
||||||
|
* and input validation.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock SSRF guard: block loopback and private IPs, allow external hostnames without DNS.
|
||||||
|
vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
checkSsrf: vi.fn().mockImplementation(async (rawUrl: string) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
const h = url.hostname;
|
||||||
|
if (h === '127.0.0.1' || h === '::1' || h === 'localhost') {
|
||||||
|
return { allowed: false, isPrivate: true, error: 'Requests to loopback addresses are not allowed' };
|
||||||
|
}
|
||||||
|
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)) {
|
||||||
|
return { allowed: false, isPrivate: true, error: 'Requests to private network addresses are not allowed' };
|
||||||
|
}
|
||||||
|
return { allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' };
|
||||||
|
} catch {
|
||||||
|
return { allowed: false, isPrivate: false, error: 'Invalid URL' };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Immich settings', () => {
|
||||||
|
it('IMMICH-001 — GET /api/immich/settings returns current settings', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/integrations/immich/settings')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Settings may be empty initially
|
||||||
|
expect(res.body).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('IMMICH-001 — PUT /api/immich/settings saves Immich URL and API key', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/integrations/immich/settings')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'test-api-key' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('IMMICH-002 — PUT /api/immich/settings with private IP is blocked by SSRF guard', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/integrations/immich/settings')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ immich_url: 'http://192.168.1.100', immich_api_key: 'test-key' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('IMMICH-002 — PUT /api/immich/settings with loopback is blocked', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/integrations/immich/settings')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ immich_url: 'http://127.0.0.1:2283', immich_api_key: 'test-key' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Immich authentication', () => {
|
||||||
|
it('GET /api/immich/settings without auth returns 401', async () => {
|
||||||
|
const res = await request(app).get('/api/integrations/immich/settings');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/immich/settings without auth returns 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/integrations/immich/settings')
|
||||||
|
.send({ url: 'https://example.com', api_key: 'key' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
135
server/tests/integration/maps.test.ts
Normal file
135
server/tests/integration/maps.test.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Maps integration tests.
|
||||||
|
* Covers MAPS-001 to MAPS-008.
|
||||||
|
*
|
||||||
|
* External API calls (Nominatim, Google Places, Wikipedia) are tested at the
|
||||||
|
* input validation level. Full integration tests would require live external APIs.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Maps authentication', () => {
|
||||||
|
it('POST /maps/search without auth returns 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/search')
|
||||||
|
.send({ query: 'Paris' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /maps/reverse without auth returns 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/maps/reverse?lat=48.8566&lng=2.3522');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Maps validation', () => {
|
||||||
|
it('MAPS-001 — POST /maps/search without query returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/search')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-006 — GET /maps/reverse without lat/lng returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/maps/reverse')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-007 — POST /maps/resolve-url without url returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/resolve-url')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Maps SSRF protection', () => {
|
||||||
|
it('MAPS-007 — POST /maps/resolve-url with internal IP is blocked', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
// SSRF: should be blocked by ssrfGuard
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/resolve-url')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ url: 'http://192.168.1.1/admin' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-007 — POST /maps/resolve-url with loopback IP is blocked', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/resolve-url')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ url: 'http://127.0.0.1/secret' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
132
server/tests/integration/mcp.test.ts
Normal file
132
server/tests/integration/mcp.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* MCP integration tests.
|
||||||
|
* Covers MCP-001 to MCP-013.
|
||||||
|
*
|
||||||
|
* The MCP endpoint uses JWT auth and server-sent events / streaming HTTP.
|
||||||
|
* Tests focus on authentication and basic rejection behavior.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser } from '../helpers/factories';
|
||||||
|
import { generateToken } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MCP authentication', () => {
|
||||||
|
// MCP handler checks if the 'mcp' addon is enabled first (403 if not),
|
||||||
|
// then checks auth (401). In test DB the addon may be disabled.
|
||||||
|
|
||||||
|
it('MCP-001 — POST /mcp without auth returns 403 (addon disabled before auth check)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/mcp')
|
||||||
|
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
|
||||||
|
// MCP handler checks addon enabled before verifying auth; addon is disabled in test DB
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MCP-001 — GET /mcp without auth returns 403 (addon disabled)', async () => {
|
||||||
|
const res = await request(app).get('/mcp');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MCP-001 — DELETE /mcp without auth returns 403 (addon disabled)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/mcp')
|
||||||
|
.set('Mcp-Session-Id', 'fake-session-id');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MCP session init', () => {
|
||||||
|
it('MCP-002 — POST /mcp with valid JWT passes auth check (may fail if addon disabled)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const token = generateToken(user.id);
|
||||||
|
|
||||||
|
// Enable MCP addon in test DB
|
||||||
|
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/mcp')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.set('Accept', 'application/json, text/event-stream')
|
||||||
|
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||||
|
// Valid JWT + enabled addon → auth passes; SDK returns 200 with session headers
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MCP-003 — DELETE /mcp with unknown session returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const token = generateToken(user.id);
|
||||||
|
|
||||||
|
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/mcp')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.set('Mcp-Session-Id', 'nonexistent-session-id');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MCP-004 — POST /mcp with invalid JWT returns 401 (when addon enabled)', async () => {
|
||||||
|
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/mcp')
|
||||||
|
.set('Authorization', 'Bearer invalid.jwt.token')
|
||||||
|
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
142
server/tests/integration/misc.test.ts
Normal file
142
server/tests/integration/misc.test.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Miscellaneous integration tests.
|
||||||
|
* Covers MISC-001, 002, 004, 007, 008, 013, 015.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Health check', () => {
|
||||||
|
it('MISC-001 — GET /api/health returns 200 with status ok', async () => {
|
||||||
|
const res = await request(app).get('/api/health');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Addons list', () => {
|
||||||
|
it('MISC-002 — GET /api/addons returns enabled addons', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.addons)).toBe(true);
|
||||||
|
// Should only return enabled addons
|
||||||
|
const enabled = (res.body.addons as any[]).filter((a: any) => !a.enabled);
|
||||||
|
expect(enabled.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Photo endpoint auth', () => {
|
||||||
|
it('MISC-007 — GET /uploads/files without auth is blocked (401)', async () => {
|
||||||
|
// /uploads/files is blocked without auth; /uploads/avatars and /uploads/covers are public static
|
||||||
|
const res = await request(app).get('/uploads/files/nonexistent.txt');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Force HTTPS redirect', () => {
|
||||||
|
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests', async () => {
|
||||||
|
// createApp() reads FORCE_HTTPS at call time, so we need a fresh app instance
|
||||||
|
process.env.FORCE_HTTPS = 'true';
|
||||||
|
let httpsApp: Express;
|
||||||
|
try {
|
||||||
|
httpsApp = createApp();
|
||||||
|
} finally {
|
||||||
|
delete process.env.FORCE_HTTPS;
|
||||||
|
}
|
||||||
|
const res = await request(httpsApp)
|
||||||
|
.get('/api/health')
|
||||||
|
.set('X-Forwarded-Proto', 'http');
|
||||||
|
expect(res.status).toBe(301);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MISC-004 — no redirect when FORCE_HTTPS is not set', async () => {
|
||||||
|
delete process.env.FORCE_HTTPS;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/health')
|
||||||
|
.set('X-Forwarded-Proto', 'http');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Categories endpoint', () => {
|
||||||
|
it('MISC-013/PLACE-015 — GET /api/categories returns seeded categories', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/categories')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.categories)).toBe(true);
|
||||||
|
expect(res.body.categories.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('App config', () => {
|
||||||
|
it('MISC-015 — GET /api/auth/app-config returns configuration', async () => {
|
||||||
|
const res = await request(app).get('/api/auth/app-config');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('allow_registration');
|
||||||
|
expect(res.body).toHaveProperty('oidc_configured');
|
||||||
|
});
|
||||||
|
});
|
||||||
177
server/tests/integration/notifications.test.ts
Normal file
177
server/tests/integration/notifications.test.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Notifications integration tests.
|
||||||
|
* Covers NOTIF-001 to NOTIF-014.
|
||||||
|
*
|
||||||
|
* External SMTP / webhook calls are not made — tests focus on preferences,
|
||||||
|
* in-app notification CRUD, and authentication.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Notification preferences', () => {
|
||||||
|
it('NOTIF-001 — GET /api/notifications/preferences returns defaults', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/notifications/preferences')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('preferences');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTIF-001 — PUT /api/notifications/preferences updates settings', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/notifications/preferences')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ notify_trip_invite: true, notify_booking_change: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('preferences');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTIF — GET preferences without auth returns 401', async () => {
|
||||||
|
const res = await request(app).get('/api/notifications/preferences');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('In-app notifications', () => {
|
||||||
|
it('NOTIF-008 — GET /api/notifications/in-app returns notifications array', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/notifications/in-app')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.notifications)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTIF-008 — GET /api/notifications/in-app/unread-count returns count', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/notifications/in-app/unread-count')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('count');
|
||||||
|
expect(typeof res.body.count).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTIF-009 — PUT /api/notifications/in-app/read-all marks all read', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/notifications/in-app/read-all')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTIF-010 — DELETE /api/notifications/in-app/all deletes all notifications', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/notifications/in-app/all')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTIF-011 — PUT /api/notifications/in-app/:id/read on non-existent returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/notifications/in-app/99999/read')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTIF-012 — DELETE /api/notifications/in-app/:id on non-existent returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/notifications/in-app/99999')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Notification test endpoints', () => {
|
||||||
|
it('NOTIF-005 — POST /api/notifications/test-smtp requires admin', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/notifications/test-smtp')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
// Non-admin gets 403
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTIF-006 — POST /api/notifications/test-webhook requires admin', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/notifications/test-webhook')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
362
server/tests/integration/packing.test.ts
Normal file
362
server/tests/integration/packing.test.ts
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
/**
|
||||||
|
* Packing List integration tests.
|
||||||
|
* Covers PACK-001 to PACK-014.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createTrip, createPackingItem, addTripMember } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Create packing item
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Create packing item', () => {
|
||||||
|
it('PACK-001 — POST creates a packing item', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/packing`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Passport', category: 'Documents' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.item.name).toBe('Passport');
|
||||||
|
expect(res.body.item.category).toBe('Documents');
|
||||||
|
expect(res.body.item.checked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PACK-001 — POST without name returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/packing`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ category: 'Clothing' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PACK-014 — non-member cannot create packing item', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/packing`)
|
||||||
|
.set('Cookie', authCookie(other.id))
|
||||||
|
.send({ name: 'Sunscreen' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// List packing items
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('List packing items', () => {
|
||||||
|
it('PACK-002 — GET /api/trips/:tripId/packing returns all items', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
|
||||||
|
createPackingItem(testDb, trip.id, { name: 'Shirt', category: 'Clothing' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/packing`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PACK-002 — member can list packing items', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
createPackingItem(testDb, trip.id, { name: 'Jacket' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/packing`)
|
||||||
|
.set('Cookie', authCookie(member.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Update packing item
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Update packing item', () => {
|
||||||
|
it('PACK-003 — PUT updates packing item (toggle checked)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const item = createPackingItem(testDb, trip.id, { name: 'Camera' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/packing/${item.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ checked: true });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.item.checked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PACK-003 — PUT returns 404 for non-existent item', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/packing/99999`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Updated' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Delete packing item
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Delete packing item', () => {
|
||||||
|
it('PACK-004 — DELETE removes packing item', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const item = createPackingItem(testDb, trip.id, { name: 'Sunglasses' });
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/packing/${item.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
|
||||||
|
const list = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/packing`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(list.body.items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Bulk import
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Bulk import packing items', () => {
|
||||||
|
it('PACK-005 — POST /import creates multiple items at once', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/packing/import`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({
|
||||||
|
items: [
|
||||||
|
{ name: 'Toothbrush', category: 'Toiletries' },
|
||||||
|
{ name: 'Shampoo', category: 'Toiletries' },
|
||||||
|
{ name: 'Socks', category: 'Clothing' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.items).toHaveLength(3);
|
||||||
|
expect(res.body.count).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PACK-005 — POST /import with empty array returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/packing/import`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ items: [] });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Reorder
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Reorder packing items', () => {
|
||||||
|
it('PACK-006 — PUT /reorder reorders items', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const i1 = createPackingItem(testDb, trip.id, { name: 'Item A' });
|
||||||
|
const i2 = createPackingItem(testDb, trip.id, { name: 'Item B' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/packing/reorder`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ orderedIds: [i2.id, i1.id] });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Bags
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Bags', () => {
|
||||||
|
it('PACK-008 — POST /bags creates a bag', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Carry-on', color: '#3b82f6' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.bag.name).toBe('Carry-on');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PACK-008 — POST /bags without name returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ color: '#ff0000' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PACK-011 — GET /bags returns bags list', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
// Create a bag
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Main Bag' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/packing/bags`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.bags).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PACK-009 — PUT /bags/:bagId updates bag', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Old Name' });
|
||||||
|
const bagId = createRes.body.bag.id;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/packing/bags/${bagId}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'New Name' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.bag.name).toBe('New Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PACK-010 — DELETE /bags/:bagId removes bag', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Temp Bag' });
|
||||||
|
const bagId = createRes.body.bag.id;
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/packing/bags/${bagId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Category assignees
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Category assignees', () => {
|
||||||
|
it('PACK-012 — PUT /category-assignees/:category sets assignees', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/packing/category-assignees/Clothing`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ user_ids: [user.id, member.id] });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.assignees).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PACK-013 — GET /category-assignees returns all category assignments', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
// Set an assignee first
|
||||||
|
await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/packing/category-assignees/Electronics`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ user_ids: [user.id] });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/packing/category-assignees`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.assignees).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
530
server/tests/integration/places.test.ts
Normal file
530
server/tests/integration/places.test.ts
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
/**
|
||||||
|
* Places API integration tests.
|
||||||
|
* Covers PLACE-001 through PLACE-019.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - PLACE-008/009: place-to-day assignment is tested in assignments.test.ts
|
||||||
|
* - PLACE-014: reordering within a day is tested in assignments.test.ts
|
||||||
|
* - PLACE-019: GPX bulk import tested here using the test fixture
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Create place
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Create place', () => {
|
||||||
|
it('PLACE-001 — POST /api/trips/:tripId/places creates place and returns 201', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.place.name).toBe('Eiffel Tower');
|
||||||
|
expect(res.body.place.lat).toBe(48.8584);
|
||||||
|
expect(res.body.place.trip_id).toBe(trip.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-001 — POST without name returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ lat: 48.8584, lng: 2.2945 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-002 — name exceeding 200 characters is rejected', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'A'.repeat(201) });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-007 — non-member cannot create a place', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(other.id))
|
||||||
|
.send({ name: 'Test Place' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-016 — create place with category assigns it correctly', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number };
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Louvre', category_id: cat.id });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.place.category).toBeDefined();
|
||||||
|
expect(res.body.place.category.id).toBe(cat.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// List places
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('List places', () => {
|
||||||
|
it('PLACE-003 — GET /api/trips/:tripId/places returns all places', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
createPlace(testDb, trip.id, { name: 'Place A' });
|
||||||
|
createPlace(testDb, trip.id, { name: 'Place B' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.places).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-003 — member can list places for a shared trip', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
createPlace(testDb, trip.id, { name: 'Shared Place' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(member.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.places).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-007 — non-member cannot list places', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(other.id));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-017 — GET /api/trips/:tripId/places?category=X filters by category id', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const cats = testDb.prepare('SELECT id, name FROM categories LIMIT 2').all() as { id: number; name: string }[];
|
||||||
|
expect(cats.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
createPlace(testDb, trip.id, { name: 'Hotel Alpha', category_id: cats[0].id });
|
||||||
|
createPlace(testDb, trip.id, { name: 'Hotel Beta', category_id: cats[0].id });
|
||||||
|
createPlace(testDb, trip.id, { name: 'Restaurant Gamma', category_id: cats[1].id });
|
||||||
|
|
||||||
|
// The route filters by category_id, not name
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/places?category=${cats[0].id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.places).toHaveLength(2);
|
||||||
|
expect(res.body.places.every((p: any) => p.category?.id === cats[0].id)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Get single place
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Get place', () => {
|
||||||
|
it('PLACE-004 — GET /api/trips/:tripId/places/:id returns place with tags', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'Test Place' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/places/${place.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.place.id).toBe(place.id);
|
||||||
|
expect(Array.isArray(res.body.place.tags)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-004 — GET non-existent place returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/places/99999`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Update place
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Update place', () => {
|
||||||
|
it('PLACE-005 — PUT /api/trips/:tripId/places/:id updates place details', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/places/${place.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'New Name', description: 'Updated description' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.place.name).toBe('New Name');
|
||||||
|
expect(res.body.place.description).toBe('Updated description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-005 — PUT returns 404 for non-existent place', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/places/99999`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'New Name' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Delete place
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Delete place', () => {
|
||||||
|
it('PLACE-006 — DELETE /api/trips/:tripId/places/:id removes place', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const place = createPlace(testDb, trip.id);
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/places/${place.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
|
||||||
|
const get = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/places/${place.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(get.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-007 — member with default permissions can delete a place', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
const place = createPlace(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/places/${place.id}`)
|
||||||
|
.set('Cookie', authCookie(member.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Tags
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Tags', () => {
|
||||||
|
it('PLACE-013 — GET /api/tags returns user tags', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
// Create a tag in DB
|
||||||
|
testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('Must-see', user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/tags')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.tags).toBeDefined();
|
||||||
|
const names = (res.body.tags as any[]).map((t: any) => t.name);
|
||||||
|
expect(names).toContain('Must-see');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-010/011 — POST place with tags associates them correctly', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
// Pre-create a tag
|
||||||
|
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('Romantic', user.id);
|
||||||
|
const tagId = tagResult.lastInsertRowid as number;
|
||||||
|
|
||||||
|
// The places API accepts `tags` as an array of tag IDs
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Dinner Spot', tags: [tagId] });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
|
||||||
|
// Get place with tags
|
||||||
|
const getRes = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/places/${res.body.place.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(getRes.body.place.tags.some((t: any) => t.id === tagId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-012 — DELETE /api/tags/:id removes tag', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('OldTag', user.id);
|
||||||
|
const tagId = tagResult.lastInsertRowid as number;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/tags/${tagId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const tags = await request(app).get('/api/tags').set('Cookie', authCookie(user.id));
|
||||||
|
expect((tags.body.tags as any[]).some((t: any) => t.id === tagId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Update place tags (PLACE-011)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Update place tags', () => {
|
||||||
|
it('PLACE-011 — PUT with tags array replaces existing tags', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const tag1Result = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('OldTag', user.id);
|
||||||
|
const tag2Result = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('NewTag', user.id);
|
||||||
|
const tag1Id = tag1Result.lastInsertRowid as number;
|
||||||
|
const tag2Id = tag2Result.lastInsertRowid as number;
|
||||||
|
|
||||||
|
// Create place with tag1
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Taggable Place', tags: [tag1Id] });
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
const placeId = createRes.body.place.id;
|
||||||
|
|
||||||
|
// Update with tag2 only — should replace tag1
|
||||||
|
const updateRes = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/places/${placeId}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ tags: [tag2Id] });
|
||||||
|
expect(updateRes.status).toBe(200);
|
||||||
|
const tags = updateRes.body.place.tags as any[];
|
||||||
|
expect(tags.some((t: any) => t.id === tag2Id)).toBe(true);
|
||||||
|
expect(tags.some((t: any) => t.id === tag1Id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-011 — PUT with empty tags array removes all tags', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('RemovableTag', user.id);
|
||||||
|
const tagId = tagResult.lastInsertRowid as number;
|
||||||
|
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Place With Tag', tags: [tagId] });
|
||||||
|
const placeId = createRes.body.place.id;
|
||||||
|
|
||||||
|
const updateRes = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/places/${placeId}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ tags: [] });
|
||||||
|
expect(updateRes.status).toBe(200);
|
||||||
|
expect(updateRes.body.place.tags).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Place notes (PLACE-018)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Place notes', () => {
|
||||||
|
it('PLACE-018 — Create a place with notes', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Noted Place', notes: 'Book in advance!' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.place.notes).toBe('Book in advance!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-018 — Update place notes via PUT', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'My Spot' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/places/${place.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ notes: 'Updated notes here' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.place.notes).toBe('Updated notes here');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Search filter (PLACE-017 search variant)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Search places', () => {
|
||||||
|
it('PLACE-017 — GET ?search= filters places by name', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||||
|
createPlace(testDb, trip.id, { name: 'Arc de Triomphe' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/places?search=Eiffel`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.places).toHaveLength(1);
|
||||||
|
expect(res.body.places[0].name).toBe('Eiffel Tower');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-017 — GET ?tag= filters by tag id', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('Scenic', user.id);
|
||||||
|
const tagId = tagResult.lastInsertRowid as number;
|
||||||
|
|
||||||
|
// Create place with the tag and one without
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Scenic Place', tags: [tagId] });
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
|
||||||
|
createPlace(testDb, trip.id, { name: 'Plain Place' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/places?tag=${tagId}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.places).toHaveLength(1);
|
||||||
|
expect(res.body.places[0].name).toBe('Scenic Place');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Categories
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Categories', () => {
|
||||||
|
it('PLACE-015 — GET /api/categories returns all categories', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/categories')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.categories)).toBe(true);
|
||||||
|
expect(res.body.categories.length).toBeGreaterThan(0);
|
||||||
|
expect(res.body.categories[0]).toHaveProperty('name');
|
||||||
|
expect(res.body.categories[0]).toHaveProperty('color');
|
||||||
|
expect(res.body.categories[0]).toHaveProperty('icon');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// GPX Import
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('GPX Import', () => {
|
||||||
|
it('PLACE-019 — POST /import/gpx with valid GPX file creates places', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places/import/gpx`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.attach('file', GPX_FIXTURE);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.places).toBeDefined();
|
||||||
|
expect(res.body.count).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-019 — POST /import/gpx without file returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places/import/gpx`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
302
server/tests/integration/profile.test.ts
Normal file
302
server/tests/integration/profile.test.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* User Profile & Settings integration tests.
|
||||||
|
* Covers PROFILE-001 to PROFILE-015.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createAdmin, createTrip } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
const FIXTURE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||||
|
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Profile
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PROFILE-001 — Get current user profile', () => {
|
||||||
|
it('returns user object with expected fields', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/auth/me')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user).toMatchObject({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
});
|
||||||
|
expect(res.body.user.password_hash).toBeUndefined();
|
||||||
|
expect(res.body.user.mfa_secret).toBeUndefined();
|
||||||
|
expect(res.body.user).toHaveProperty('mfa_enabled');
|
||||||
|
expect(res.body.user).toHaveProperty('must_change_password');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Avatar', () => {
|
||||||
|
it('PROFILE-002 — upload valid JPEG avatar updates avatar_url', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/avatar')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.attach('avatar', FIXTURE_JPEG);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.avatar_url).toBeDefined();
|
||||||
|
expect(typeof res.body.avatar_url).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PROFILE-003 — uploading non-image (PDF) is rejected', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/avatar')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.attach('avatar', FIXTURE_PDF);
|
||||||
|
// multer fileFilter rejects non-image types (cb(null, false) → req.file undefined → 400)
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PROFILE-005 — DELETE /api/auth/avatar clears avatar_url', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
// Upload first
|
||||||
|
await request(app)
|
||||||
|
.post('/api/auth/avatar')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.attach('avatar', FIXTURE_JPEG);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/auth/avatar')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const me = await request(app)
|
||||||
|
.get('/api/auth/me')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(me.body.user.avatar_url).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Password change', () => {
|
||||||
|
it('PROFILE-006 — change password with valid credentials succeeds', async () => {
|
||||||
|
const { user, password } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/auth/me/password')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ current_password: password, new_password: 'NewStr0ng!Pass', confirm_password: 'NewStr0ng!Pass' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PROFILE-007 — wrong current password returns 401', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/auth/me/password')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ current_password: 'WrongPass1!', new_password: 'NewStr0ng!Pass', confirm_password: 'NewStr0ng!Pass' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PROFILE-008 — weak new password is rejected', async () => {
|
||||||
|
const { user, password } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/auth/me/password')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ current_password: password, new_password: 'weak', confirm_password: 'weak' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settings', () => {
|
||||||
|
it('PROFILE-009 — PUT /api/settings with key+value persists and GET returns it', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const put = await request(app)
|
||||||
|
.put('/api/settings')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ key: 'dark_mode', value: 'dark' });
|
||||||
|
expect(put.status).toBe(200);
|
||||||
|
|
||||||
|
const get = await request(app)
|
||||||
|
.get('/api/settings')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(get.status).toBe(200);
|
||||||
|
expect(get.body.settings).toHaveProperty('dark_mode', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PROFILE-009 — PUT /api/settings without key returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/settings')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ value: 'dark' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PROFILE-010 — POST /api/settings/bulk saves multiple keys atomically', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/settings/bulk')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ settings: { theme: 'dark', language: 'fr', timezone: 'Europe/Paris' } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
const get = await request(app)
|
||||||
|
.get('/api/settings')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(get.body.settings).toHaveProperty('theme', 'dark');
|
||||||
|
expect(get.body.settings).toHaveProperty('language', 'fr');
|
||||||
|
expect(get.body.settings).toHaveProperty('timezone', 'Europe/Paris');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API Keys', () => {
|
||||||
|
it('PROFILE-011 — PUT /api/auth/me/api-keys saves keys encrypted at rest', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/auth/me/api-keys')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ openweather_api_key: 'my-weather-key-123' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// Key in DB should be encrypted (not plaintext)
|
||||||
|
const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any;
|
||||||
|
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PROFILE-011 — GET /api/auth/me does not return plaintext API keys', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app)
|
||||||
|
.put('/api/auth/me/api-keys')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ openweather_api_key: 'plaintext-key' });
|
||||||
|
|
||||||
|
const me = await request(app)
|
||||||
|
.get('/api/auth/me')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
// The key should be masked or absent, never plaintext
|
||||||
|
const body = me.body.user;
|
||||||
|
expect(body.openweather_api_key).not.toBe('plaintext-key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Account deletion', () => {
|
||||||
|
it('PROFILE-013 — DELETE /api/auth/me removes account, subsequent login fails', async () => {
|
||||||
|
const { user, password } = createUser(testDb);
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete('/api/auth/me')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
|
||||||
|
// Should not be able to log in
|
||||||
|
const login = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ email: user.email, password });
|
||||||
|
expect(login.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PROFILE-013 — admin cannot delete their own account', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
// Admins are protected from self-deletion
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/auth/me')
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
// deleteAccount returns 400 when the user is the last admin
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Travel stats', () => {
|
||||||
|
it('PROFILE-014 — GET /api/auth/travel-stats returns stats object', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
createTrip(testDb, user.id, {
|
||||||
|
title: 'France Trip',
|
||||||
|
start_date: '2024-06-01',
|
||||||
|
end_date: '2024-06-05',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/auth/travel-stats')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('totalTrips');
|
||||||
|
expect(res.body.totalTrips).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Demo mode protections', () => {
|
||||||
|
it('PROFILE-015 — demo user cannot upload avatar (demoUploadBlock)', async () => {
|
||||||
|
// demoUploadBlock checks for email === 'demo@nomad.app'
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@nomad.app', 'x', 'user')"
|
||||||
|
).run();
|
||||||
|
const demoUser = testDb.prepare('SELECT id FROM users WHERE email = ?').get('demo@nomad.app') as { id: number };
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/avatar')
|
||||||
|
.set('Cookie', authCookie(demoUser.id))
|
||||||
|
.attach('avatar', FIXTURE_JPEG);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
} finally {
|
||||||
|
delete process.env.DEMO_MODE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
243
server/tests/integration/reservations.test.ts
Normal file
243
server/tests/integration/reservations.test.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Reservations integration tests.
|
||||||
|
* Covers RESV-001 to RESV-007.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createTrip, createDay, createReservation, addTripMember } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Create reservation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Create reservation', () => {
|
||||||
|
it('RESV-001 — POST /api/trips/:tripId/reservations creates a reservation', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/reservations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Hotel Check-in', type: 'hotel' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.reservation.title).toBe('Hotel Check-in');
|
||||||
|
expect(res.body.reservation.type).toBe('hotel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RESV-001 — POST without title returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/reservations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ type: 'hotel' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RESV-001 — non-member cannot create reservation', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/reservations`)
|
||||||
|
.set('Cookie', authCookie(other.id))
|
||||||
|
.send({ title: 'Hotel', type: 'hotel' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RESV-002 — POST with create_accommodation creates an accommodation record', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/reservations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Grand Hotel', type: 'hotel', day_id: day.id, create_accommodation: true });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.reservation).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// List reservations
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('List reservations', () => {
|
||||||
|
it('RESV-003 — GET /api/trips/:tripId/reservations returns all reservations', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
createReservation(testDb, trip.id, { title: 'Flight Out', type: 'flight' });
|
||||||
|
createReservation(testDb, trip.id, { title: 'Hotel Stay', type: 'hotel' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/reservations`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.reservations).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RESV-003 — returns empty array when no reservations exist', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/reservations`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.reservations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RESV-007 — non-member cannot list reservations', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/reservations`)
|
||||||
|
.set('Cookie', authCookie(other.id));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Update reservation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Update reservation', () => {
|
||||||
|
it('RESV-004 — PUT updates reservation fields', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const resv = createReservation(testDb, trip.id, { title: 'Old Flight', type: 'flight' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/reservations/${resv.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'New Flight', confirmation_number: 'ABC123' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.reservation.title).toBe('New Flight');
|
||||||
|
expect(res.body.reservation.confirmation_number).toBe('ABC123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RESV-004 — PUT on non-existent reservation returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/reservations/99999`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Updated' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Delete reservation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Delete reservation', () => {
|
||||||
|
it('RESV-005 — DELETE removes reservation', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const resv = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' });
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/reservations/${resv.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
|
||||||
|
const list = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/reservations`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(list.body.reservations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RESV-005 — DELETE non-existent reservation returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/reservations/99999`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Batch update positions
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Batch update positions', () => {
|
||||||
|
it('RESV-006 — PUT /positions updates reservation sort order', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const r1 = createReservation(testDb, trip.id, { title: 'First', type: 'flight' });
|
||||||
|
const r2 = createReservation(testDb, trip.id, { title: 'Second', type: 'hotel' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/reservations/positions`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ positions: [{ id: r2.id, position: 0 }, { id: r1.id, position: 1 }] });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
173
server/tests/integration/security.test.ts
Normal file
173
server/tests/integration/security.test.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Security integration tests.
|
||||||
|
* Covers SEC-001 to SEC-015.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - SSRF tests (SEC-001 to SEC-004) are unit-level tests on ssrfGuard — see tests/unit/utils/ssrfGuard.test.ts
|
||||||
|
* - SEC-015 (MFA backup codes) is covered in auth.test.ts
|
||||||
|
* - These tests focus on HTTP-level security: headers, auth, injection protection, etc.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser } from '../helpers/factories';
|
||||||
|
import { authCookie, generateToken } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Authentication security', () => {
|
||||||
|
it('SEC-007 — JWT in Authorization Bearer header authenticates user', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const token = generateToken(user.id);
|
||||||
|
|
||||||
|
// The file download endpoint accepts bearer auth
|
||||||
|
// Other endpoints use cookie auth — but /api/auth/me works with cookie auth
|
||||||
|
// Test that a forged/invalid JWT is rejected
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/auth/me')
|
||||||
|
.set('Authorization', 'Bearer invalid.token.here');
|
||||||
|
// Should return 401 (auth fails)
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unauthenticated request to protected endpoint returns 401', async () => {
|
||||||
|
const res = await request(app).get('/api/trips');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expired/invalid JWT cookie returns 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/trips')
|
||||||
|
.set('Cookie', 'trek_session=invalid.jwt.token');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Security headers', () => {
|
||||||
|
it('SEC-011 — Helmet sets X-Content-Type-Options header', async () => {
|
||||||
|
const res = await request(app).get('/api/health');
|
||||||
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-011 — Helmet sets X-Frame-Options header', async () => {
|
||||||
|
const res = await request(app).get('/api/health');
|
||||||
|
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API key encryption', () => {
|
||||||
|
it('SEC-008 — encrypted API keys are stored with enc:v1: prefix', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.put('/api/auth/me/api-keys')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ openweather_api_key: 'test-api-key-12345' });
|
||||||
|
|
||||||
|
const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any;
|
||||||
|
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-008 — GET /api/auth/me does not return plaintext API key', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app)
|
||||||
|
.put('/api/auth/me/api-keys')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ openweather_api_key: 'secret-key' });
|
||||||
|
|
||||||
|
const me = await request(app)
|
||||||
|
.get('/api/auth/me')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(me.body.user.openweather_api_key).not.toBe('secret-key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MFA secret protection', () => {
|
||||||
|
it('SEC-009 — GET /api/auth/me does not expose mfa_secret', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/auth/me')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.body.user.mfa_secret).toBeUndefined();
|
||||||
|
expect(res.body.user.password_hash).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Request body size limit', () => {
|
||||||
|
it('SEC-013 — oversized JSON body is rejected', async () => {
|
||||||
|
// Send a large body (2MB+) to exceed the default limit
|
||||||
|
const bigData = { data: 'x'.repeat(2 * 1024 * 1024) };
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send(bigData);
|
||||||
|
// body-parser rejects oversized payloads with 413
|
||||||
|
expect(res.status).toBe(413);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('File download path traversal', () => {
|
||||||
|
it('SEC-005 — path traversal in file download is blocked', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = { id: 1 };
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/files/1/download`)
|
||||||
|
.set('Authorization', `Bearer ${generateToken(user.id)}`);
|
||||||
|
// Trip 1 does not exist after resetTestDb → 404 before any file path is evaluated
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
207
server/tests/integration/share.test.ts
Normal file
207
server/tests/integration/share.test.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Share link integration tests.
|
||||||
|
* Covers SHARE-001 to SHARE-009.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Share link CRUD', () => {
|
||||||
|
it('SHARE-001 — POST creates share link with default permissions', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.token).toBeDefined();
|
||||||
|
expect(typeof res.body.token).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-002 — POST creates share link with custom permissions', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ share_budget: false, share_packing: true });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.token).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-003 — POST again updates share link permissions', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const first = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ share_budget: true });
|
||||||
|
|
||||||
|
const second = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ share_budget: false });
|
||||||
|
// Same token (update, not create)
|
||||||
|
expect(second.body.token).toBe(first.body.token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-004 — GET returns share link status', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.token).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-004 — GET returns null token when no share link exists', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.token).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-005 — DELETE removes share link', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
|
||||||
|
const status = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(status.body.token).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Shared trip access', () => {
|
||||||
|
it('SHARE-006 — GET /shared/:token returns trip data with all sections', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Paris Adventure' });
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ share_budget: true, share_packing: true });
|
||||||
|
const token = create.body.token;
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/shared/${token}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.trip).toBeDefined();
|
||||||
|
expect(res.body.trip.title).toBe('Paris Adventure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-007 — GET /shared/:token hides budget when share_budget=false', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const create = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ share_budget: false });
|
||||||
|
const token = create.body.token;
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/shared/${token}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Budget should be an empty array when share_budget is false
|
||||||
|
expect(Array.isArray(res.body.budget)).toBe(true);
|
||||||
|
expect(res.body.budget).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-008 — GET /shared/:invalid-token returns 404', async () => {
|
||||||
|
const res = await request(app).get('/api/shared/invalid-token-xyz');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-009 — non-member cannot create share link', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(other.id))
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
679
server/tests/integration/trips.test.ts
Normal file
679
server/tests/integration/trips.test.ts
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
/**
|
||||||
|
* Trips API integration tests.
|
||||||
|
* Covers TRIP-001 through TRIP-022.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`
|
||||||
|
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
|
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||||
|
`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
invalidatePermissionsCache();
|
||||||
|
});
|
||||||
|
afterAll(() => { testDb.close(); });
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Create trip (TRIP-001, TRIP-002, TRIP-003)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Create trip', () => {
|
||||||
|
it('TRIP-001 — POST /api/trips with start_date/end_date returns 201 and auto-generates days', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/trips')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Paris Adventure', start_date: '2026-06-01', end_date: '2026-06-05' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.trip).toBeDefined();
|
||||||
|
expect(res.body.trip.title).toBe('Paris Adventure');
|
||||||
|
|
||||||
|
// Verify days were generated (5 days: Jun 1–5)
|
||||||
|
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY date').all(res.body.trip.id) as any[];
|
||||||
|
expect(days).toHaveLength(5);
|
||||||
|
expect(days[0].date).toBe('2026-06-01');
|
||||||
|
expect(days[4].date).toBe('2026-06-05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-002 — POST /api/trips without dates returns 201 and no date-specific days', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/trips')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Open-ended Trip' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.trip).toBeDefined();
|
||||||
|
expect(res.body.trip.start_date).toBeNull();
|
||||||
|
expect(res.body.trip.end_date).toBeNull();
|
||||||
|
|
||||||
|
// Days with explicit dates should not be present
|
||||||
|
const daysWithDate = testDb.prepare('SELECT * FROM days WHERE trip_id = ? AND date IS NOT NULL').all(res.body.trip.id) as any[];
|
||||||
|
expect(daysWithDate).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-001 — POST /api/trips requires a title, returns 400 without one', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/trips')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ description: 'No title here' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/title/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-001 — POST /api/trips rejects end_date before start_date with 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/trips')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Bad Dates', start_date: '2026-06-10', end_date: '2026-06-05' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/end date/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-003 — trip_create permission set to admin blocks regular user with 403', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
// Restrict trip creation to admins only
|
||||||
|
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('perm_trip_create', 'admin')").run();
|
||||||
|
invalidatePermissionsCache();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/trips')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Forbidden Trip' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/permission/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-003 — trip_create permission set to admin allows admin user', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
|
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('perm_trip_create', 'admin')").run();
|
||||||
|
invalidatePermissionsCache();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/trips')
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ title: 'Admin Trip' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-001 — unauthenticated POST /api/trips returns 401', async () => {
|
||||||
|
const res = await request(app).post('/api/trips').send({ title: 'No Auth' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// List trips (TRIP-004, TRIP-005)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('List trips', () => {
|
||||||
|
it('TRIP-004 — GET /api/trips returns own trips and member trips, not other users trips', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const { user: stranger } = createUser(testDb);
|
||||||
|
|
||||||
|
const ownTrip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
|
||||||
|
const memberTrip = createTrip(testDb, stranger.id, { title: "Stranger's Trip (member)" });
|
||||||
|
createTrip(testDb, stranger.id, { title: "Stranger's Private Trip" });
|
||||||
|
|
||||||
|
// Add member to one of stranger's trips
|
||||||
|
addTripMember(testDb, memberTrip.id, member.id);
|
||||||
|
|
||||||
|
const ownerRes = await request(app)
|
||||||
|
.get('/api/trips')
|
||||||
|
.set('Cookie', authCookie(owner.id));
|
||||||
|
|
||||||
|
expect(ownerRes.status).toBe(200);
|
||||||
|
const ownerTripIds = ownerRes.body.trips.map((t: any) => t.id);
|
||||||
|
expect(ownerTripIds).toContain(ownTrip.id);
|
||||||
|
expect(ownerTripIds).not.toContain(memberTrip.id);
|
||||||
|
|
||||||
|
const memberRes = await request(app)
|
||||||
|
.get('/api/trips')
|
||||||
|
.set('Cookie', authCookie(member.id));
|
||||||
|
|
||||||
|
expect(memberRes.status).toBe(200);
|
||||||
|
const memberTripIds = memberRes.body.trips.map((t: any) => t.id);
|
||||||
|
expect(memberTripIds).toContain(memberTrip.id);
|
||||||
|
expect(memberTripIds).not.toContain(ownTrip.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-005 — GET /api/trips excludes archived trips by default', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const activeTrip = createTrip(testDb, user.id, { title: 'Active Trip' });
|
||||||
|
const archivedTrip = createTrip(testDb, user.id, { title: 'Archived Trip' });
|
||||||
|
|
||||||
|
// Archive the second trip directly in the DB
|
||||||
|
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archivedTrip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/trips')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const tripIds = res.body.trips.map((t: any) => t.id);
|
||||||
|
expect(tripIds).toContain(activeTrip.id);
|
||||||
|
expect(tripIds).not.toContain(archivedTrip.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-005 — GET /api/trips?archived=1 returns only archived trips', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const activeTrip = createTrip(testDb, user.id, { title: 'Active Trip' });
|
||||||
|
const archivedTrip = createTrip(testDb, user.id, { title: 'Archived Trip' });
|
||||||
|
|
||||||
|
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archivedTrip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/trips?archived=1')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const tripIds = res.body.trips.map((t: any) => t.id);
|
||||||
|
expect(tripIds).toContain(archivedTrip.id);
|
||||||
|
expect(tripIds).not.toContain(activeTrip.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Get trip (TRIP-006, TRIP-007, TRIP-016, TRIP-017)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Get trip', () => {
|
||||||
|
it('TRIP-006 — GET /api/trips/:id for own trip returns 200 with full trip object', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'My Trip', description: 'A lovely trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.trip).toBeDefined();
|
||||||
|
expect(res.body.trip.id).toBe(trip.id);
|
||||||
|
expect(res.body.trip.title).toBe('My Trip');
|
||||||
|
expect(res.body.trip.is_owner).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-007 — GET /api/trips/:id for another users trip returns 404', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(other.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-016 — Non-member cannot access trip → 404', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: nonMember } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(nonMember.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-017 — Member can access trip → 200', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(member.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.trip.id).toBe(trip.id);
|
||||||
|
expect(res.body.trip.is_owner).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-006 — GET /api/trips/:id for non-existent trip returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/trips/999999')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Update trip (TRIP-008, TRIP-009, TRIP-010)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Update trip', () => {
|
||||||
|
it('TRIP-008 — PUT /api/trips/:id updates title and description for owner → 200', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Original Title' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Updated Title', description: 'New description' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.trip.title).toBe('Updated Title');
|
||||||
|
expect(res.body.trip.description).toBe('New description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-009 — Archive trip (PUT with is_archived:true) removes it from normal list', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'To Archive' });
|
||||||
|
|
||||||
|
const archiveRes = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ is_archived: true });
|
||||||
|
|
||||||
|
expect(archiveRes.status).toBe(200);
|
||||||
|
expect(archiveRes.body.trip.is_archived).toBe(1);
|
||||||
|
|
||||||
|
// Should not appear in the normal list
|
||||||
|
const listRes = await request(app)
|
||||||
|
.get('/api/trips')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const tripIds = listRes.body.trips.map((t: any) => t.id);
|
||||||
|
expect(tripIds).not.toContain(trip.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-009 — Unarchive trip reappears in normal list', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Archived Trip' });
|
||||||
|
|
||||||
|
// Archive it first
|
||||||
|
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(trip.id);
|
||||||
|
|
||||||
|
// Unarchive via API
|
||||||
|
const unarchiveRes = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ is_archived: false });
|
||||||
|
|
||||||
|
expect(unarchiveRes.status).toBe(200);
|
||||||
|
expect(unarchiveRes.body.trip.is_archived).toBe(0);
|
||||||
|
|
||||||
|
// Should appear in the normal list again
|
||||||
|
const listRes = await request(app)
|
||||||
|
.get('/api/trips')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const tripIds = listRes.body.trips.map((t: any) => t.id);
|
||||||
|
expect(tripIds).toContain(trip.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-010 — Archive by trip member is denied when trip_archive is set to trip_owner', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Members Trip' });
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
// Restrict archiving to trip_owner only (this is actually the default, but set explicitly)
|
||||||
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_trip_archive', 'trip_owner')").run();
|
||||||
|
invalidatePermissionsCache();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(member.id))
|
||||||
|
.send({ is_archived: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/permission/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-008 — Member cannot edit trip title when trip_edit is set to trip_owner', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Original' });
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
// Default trip_edit is trip_owner — members should be blocked
|
||||||
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_trip_edit', 'trip_owner')").run();
|
||||||
|
invalidatePermissionsCache();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(member.id))
|
||||||
|
.send({ title: 'Hacked Title' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-008 — PUT /api/trips/:id returns 404 for non-existent trip', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/trips/999999')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Ghost Update' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Delete trip (TRIP-018, TRIP-019, TRIP-022)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Delete trip', () => {
|
||||||
|
it('TRIP-018 — DELETE /api/trips/:id by owner returns 200 and trip is no longer accessible', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'To Delete' });
|
||||||
|
|
||||||
|
const deleteRes = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(deleteRes.status).toBe(200);
|
||||||
|
expect(deleteRes.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Trip should no longer be accessible
|
||||||
|
const getRes = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(getRes.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-019 — Regular user cannot delete another users trip → 403', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(other.id));
|
||||||
|
|
||||||
|
// getTripOwner finds the trip (it exists); checkPermission fails for non-members → 403
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
|
||||||
|
// Trip still exists
|
||||||
|
const tripInDb = testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id);
|
||||||
|
expect(tripInDb).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-019 — Trip member cannot delete trip → 403', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(member.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/permission/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-022 — Trip with places and reservations can be deleted (cascade)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { title: 'Trip With Data' });
|
||||||
|
|
||||||
|
// Add associated data
|
||||||
|
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||||
|
createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' });
|
||||||
|
|
||||||
|
const deleteRes = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(deleteRes.status).toBe(200);
|
||||||
|
expect(deleteRes.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify cascade: places and reservations should be gone
|
||||||
|
const places = testDb.prepare('SELECT id FROM places WHERE trip_id = ?').all(trip.id);
|
||||||
|
expect(places).toHaveLength(0);
|
||||||
|
|
||||||
|
const reservations = testDb.prepare('SELECT id FROM reservations WHERE trip_id = ?').all(trip.id);
|
||||||
|
expect(reservations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-018 — Admin can delete another users trip', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: "User's Trip" });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}`)
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-018 — DELETE /api/trips/:id for non-existent trip returns 404', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/trips/999999')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Members (TRIP-013, TRIP-014, TRIP-015)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Trip members', () => {
|
||||||
|
it('TRIP-015 — GET /api/trips/:id/members returns owner and members list', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/members`)
|
||||||
|
.set('Cookie', authCookie(owner.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.owner).toBeDefined();
|
||||||
|
expect(res.body.owner.id).toBe(owner.id);
|
||||||
|
expect(Array.isArray(res.body.members)).toBe(true);
|
||||||
|
expect(res.body.members.some((m: any) => m.id === member.id)).toBe(true);
|
||||||
|
expect(res.body.current_user_id).toBe(owner.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-013 — POST /api/trips/:id/members adds a member by email → 201', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: invitee } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/members`)
|
||||||
|
.set('Cookie', authCookie(owner.id))
|
||||||
|
.send({ identifier: invitee.email });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.member).toBeDefined();
|
||||||
|
expect(res.body.member.email).toBe(invitee.email);
|
||||||
|
expect(res.body.member.role).toBe('member');
|
||||||
|
|
||||||
|
// Verify in DB
|
||||||
|
const dbEntry = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id);
|
||||||
|
expect(dbEntry).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-013 — POST /api/trips/:id/members adds a member by username → 201', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: invitee } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/members`)
|
||||||
|
.set('Cookie', authCookie(owner.id))
|
||||||
|
.send({ identifier: invitee.username });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.member.id).toBe(invitee.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-013 — Adding a non-existent user returns 404', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/members`)
|
||||||
|
.set('Cookie', authCookie(owner.id))
|
||||||
|
.send({ identifier: 'nobody@nowhere.example.com' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/user not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-013 — Adding a user who is already a member returns 400', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/members`)
|
||||||
|
.set('Cookie', authCookie(owner.id))
|
||||||
|
.send({ identifier: member.email });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/already/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-014 — DELETE /api/trips/:id/members/:userId removes a member → 200', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/members/${member.id}`)
|
||||||
|
.set('Cookie', authCookie(owner.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify removal in DB
|
||||||
|
const dbEntry = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
|
||||||
|
expect(dbEntry).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-014 — Member can remove themselves from a trip → 200', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/members/${member.id}`)
|
||||||
|
.set('Cookie', authCookie(member.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-013 — Non-owner member cannot add other members when member_manage is trip_owner', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const { user: invitee } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
// Restrict member management to trip_owner (default)
|
||||||
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_member_manage', 'trip_owner')").run();
|
||||||
|
invalidatePermissionsCache();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/members`)
|
||||||
|
.set('Cookie', authCookie(member.id))
|
||||||
|
.send({ identifier: invitee.email });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/permission/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-015 — Non-member cannot list trip members → 404', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: stranger } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/members`)
|
||||||
|
.set('Cookie', authCookie(stranger.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
306
server/tests/integration/vacay.test.ts
Normal file
306
server/tests/integration/vacay.test.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* Vacay integration tests.
|
||||||
|
* Covers VACAY-001 to VACAY-025.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock external holiday API (node-fetch used by some service paths)
|
||||||
|
vi.mock('node-fetch', () => ({
|
||||||
|
default: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([
|
||||||
|
{ date: '2025-01-01', name: 'New Year\'s Day', countryCode: 'DE' },
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock vacayService.getCountries to avoid real HTTP call to nager.at
|
||||||
|
vi.mock('../../src/services/vacayService', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../../src/services/vacayService')>('../../src/services/vacayService');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getCountries: vi.fn().mockResolvedValue({
|
||||||
|
data: [{ countryCode: 'DE', name: 'Germany' }, { countryCode: 'FR', name: 'France' }],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Vacay plan', () => {
|
||||||
|
it('VACAY-001 — GET /api/addons/vacay/plan auto-creates plan on first access', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/vacay/plan')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.plan).toBeDefined();
|
||||||
|
expect(res.body.plan.owner_id).toBe(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-001 — second GET returns same plan (no duplicate creation)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
const res = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.plan).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-002 — PUT /api/addons/vacay/plan updates plan settings', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
// Ensure plan exists
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/addons/vacay/plan')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ vacation_days: 30, carry_over_days: 5 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Vacay years', () => {
|
||||||
|
it('VACAY-007 — POST /api/addons/vacay/years adds a year to the plan', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/vacay/years')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ year: 2025 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.years).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-025 — GET /api/addons/vacay/years lists years in plan', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/vacay/years')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.years)).toBe(true);
|
||||||
|
expect(res.body.years.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-008 — DELETE /api/addons/vacay/years/:year removes year', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2026 });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/addons/vacay/years/2026')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.years).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-011 — PUT /api/addons/vacay/stats/:year updates allowance', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/addons/vacay/stats/2025')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ vacation_days: 28 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Vacay entries', () => {
|
||||||
|
it('VACAY-003 — POST /api/addons/vacay/entries/toggle marks a day as vacation', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/vacay/entries/toggle')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ date: '2025-06-16', year: 2025, type: 'vacation' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-004 — POST /api/addons/vacay/entries/toggle on weekend is allowed (no server-side weekend blocking)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||||
|
|
||||||
|
// 2025-06-21 is a Saturday — server does not block weekends; client-side only
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/vacay/entries/toggle')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ date: '2025-06-21', year: 2025, type: 'vacation' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-006 — GET /api/addons/vacay/entries/:year returns vacation entries', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/vacay/entries/2025')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.entries)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-009 — GET /api/addons/vacay/stats/:year returns stats for year', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/vacay/stats/2025')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('stats');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Vacay color', () => {
|
||||||
|
it('VACAY-024 — PUT /api/addons/vacay/color sets user color in plan', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/addons/vacay/color')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ color: '#3b82f6' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Vacay invite flow', () => {
|
||||||
|
it('VACAY-022 — cannot invite yourself', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/vacay/invite')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ user_id: user.id });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-016 — send invite to another user', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: invitee } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/vacay/invite')
|
||||||
|
.set('Cookie', authCookie(owner.id))
|
||||||
|
.send({ user_id: invitee.id });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-023 — GET /api/addons/vacay/available-users returns users who can be invited', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/vacay/available-users')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.users)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Vacay holidays', () => {
|
||||||
|
it('VACAY-014 — GET /api/addons/vacay/holidays/countries returns available countries', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/vacay/holidays/countries')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-012 — POST /api/addons/vacay/plan/holiday-calendars adds a holiday calendar', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/vacay/plan/holiday-calendars')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ region: 'DE', label: 'Germany Holidays' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Vacay dissolve plan', () => {
|
||||||
|
it('VACAY-020 — POST /api/addons/vacay/dissolve removes user from plan', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/vacay/dissolve')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
157
server/tests/integration/weather.test.ts
Normal file
157
server/tests/integration/weather.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Weather integration tests.
|
||||||
|
* Covers WEATHER-001 to WEATHER-007.
|
||||||
|
*
|
||||||
|
* External API calls (Open-Meteo) are mocked via vi.mock.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Application } from 'express';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock node-fetch / global fetch so no real HTTP calls are made
|
||||||
|
vi.mock('node-fetch', () => ({
|
||||||
|
default: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
current: { temperature_2m: 22, weathercode: 1, windspeed_10m: 10, relativehumidity_2m: 60, precipitation: 0 },
|
||||||
|
daily: {
|
||||||
|
time: ['2025-06-01'],
|
||||||
|
temperature_2m_max: [25],
|
||||||
|
temperature_2m_min: [18],
|
||||||
|
weathercode: [1],
|
||||||
|
precipitation_sum: [0],
|
||||||
|
windspeed_10m_max: [15],
|
||||||
|
sunrise: ['2025-06-01T06:00'],
|
||||||
|
sunset: ['2025-06-01T21:00'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
|
const app: Application = createApp();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Weather validation', () => {
|
||||||
|
it('WEATHER-001 — GET /weather without lat/lng returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/weather')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WEATHER-001 — GET /weather without lng returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/weather?lat=48.8566')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WEATHER-005 — GET /weather/detailed without date returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/weather/detailed?lat=48.8566&lng=2.3522')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WEATHER-001 — GET /weather without auth returns 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/weather?lat=48.8566&lng=2.3522');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Weather with mocked API', () => {
|
||||||
|
it('WEATHER-001 — GET /weather with lat/lng returns weather data', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/weather?lat=48.8566&lng=2.3522')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('temp');
|
||||||
|
expect(res.body).toHaveProperty('main');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WEATHER-002 — GET /weather?date=future returns forecast data', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setDate(futureDate.getDate() + 5);
|
||||||
|
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/weather?lat=48.8566&lng=2.3522&date=${dateStr}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('temp');
|
||||||
|
expect(res.body).toHaveProperty('type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WEATHER-006 — GET /weather accepts lang parameter', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/weather?lat=48.8566&lng=2.3522&lang=en')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('temp');
|
||||||
|
});
|
||||||
|
});
|
||||||
9
server/tests/setup.ts
Normal file
9
server/tests/setup.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Global test setup — runs before every test file.
|
||||||
|
// Environment variables must be set before any module import so that
|
||||||
|
// config.ts, database.ts, etc. pick them up at import time.
|
||||||
|
|
||||||
|
// Fixed encryption key (64 hex chars = 32 bytes) for at-rest crypto in tests
|
||||||
|
process.env.ENCRYPTION_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2';
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.COOKIE_SECURE = 'false';
|
||||||
|
process.env.LOG_LEVEL = 'error'; // suppress info/debug logs in test output
|
||||||
115
server/tests/unit/middleware/auth.test.ts
Normal file
115
server/tests/unit/middleware/auth.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => ({
|
||||||
|
db: { prepare: () => ({ get: vi.fn(), all: vi.fn() }) },
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
|
||||||
|
|
||||||
|
import { extractToken, authenticate, adminOnly } from '../../../src/middleware/auth';
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
function makeReq(overrides: {
|
||||||
|
cookies?: Record<string, string>;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
} = {}): Request {
|
||||||
|
return {
|
||||||
|
cookies: overrides.cookies || {},
|
||||||
|
headers: overrides.headers || {},
|
||||||
|
} as unknown as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRes(): { res: Response; status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn> } {
|
||||||
|
const json = vi.fn();
|
||||||
|
const status = vi.fn(() => ({ json }));
|
||||||
|
const res = { status } as unknown as Response;
|
||||||
|
return { res, status, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── extractToken ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('extractToken', () => {
|
||||||
|
it('returns cookie value when trek_session cookie is set', () => {
|
||||||
|
const req = makeReq({ cookies: { trek_session: 'cookie-token' } });
|
||||||
|
expect(extractToken(req)).toBe('cookie-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Bearer token from Authorization header when no cookie', () => {
|
||||||
|
const req = makeReq({ headers: { authorization: 'Bearer header-token' } });
|
||||||
|
expect(extractToken(req)).toBe('header-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers cookie over Authorization header when both are present', () => {
|
||||||
|
const req = makeReq({
|
||||||
|
cookies: { trek_session: 'cookie-token' },
|
||||||
|
headers: { authorization: 'Bearer header-token' },
|
||||||
|
});
|
||||||
|
expect(extractToken(req)).toBe('cookie-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when neither cookie nor header are present', () => {
|
||||||
|
expect(extractToken(makeReq())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for Authorization header without a token (empty Bearer)', () => {
|
||||||
|
const req = makeReq({ headers: { authorization: 'Bearer ' } });
|
||||||
|
expect(extractToken(req)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for Authorization header without Bearer prefix', () => {
|
||||||
|
const req = makeReq({ headers: { authorization: 'Basic sometoken' } });
|
||||||
|
// split(' ')[1] returns 'sometoken' — this IS returned (not a null case)
|
||||||
|
// The function simply splits on space and takes index 1
|
||||||
|
expect(extractToken(req)).toBe('sometoken');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── authenticate ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('authenticate', () => {
|
||||||
|
it('returns 401 when no token is present', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res, status, json } = makeRes();
|
||||||
|
authenticate(makeReq(), res, next);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(status).toHaveBeenCalledWith(401);
|
||||||
|
expect(json).toHaveBeenCalledWith(expect.objectContaining({ code: 'AUTH_REQUIRED' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when JWT is invalid', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res, status } = makeRes();
|
||||||
|
authenticate(makeReq({ cookies: { trek_session: 'invalid.jwt.token' } }), res, next);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(status).toHaveBeenCalledWith(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── adminOnly ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('adminOnly', () => {
|
||||||
|
it('returns 403 when user role is not admin', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res, status, json } = makeRes();
|
||||||
|
const req = { ...makeReq(), user: { id: 1, role: 'user' } } as unknown as Request;
|
||||||
|
adminOnly(req, res, next);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(status).toHaveBeenCalledWith(403);
|
||||||
|
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringContaining('Admin') }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls next() when user role is admin', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res } = makeRes();
|
||||||
|
const req = { ...makeReq(), user: { id: 1, role: 'admin' } } as unknown as Request;
|
||||||
|
adminOnly(req, res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 when req.user is undefined', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res, status } = makeRes();
|
||||||
|
adminOnly(makeReq() as unknown as Request, res, next);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(status).toHaveBeenCalledWith(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
100
server/tests/unit/middleware/mfaPolicy.test.ts
Normal file
100
server/tests/unit/middleware/mfaPolicy.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => ({
|
||||||
|
db: { prepare: () => ({ get: vi.fn(), all: vi.fn() }) },
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
|
||||||
|
|
||||||
|
import { isPublicApiPath, isMfaSetupExemptPath } from '../../../src/middleware/mfaPolicy';
|
||||||
|
|
||||||
|
// ── isPublicApiPath ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('isPublicApiPath', () => {
|
||||||
|
// AUTH-001 — Public paths must bypass MFA
|
||||||
|
it('AUTH-001: GET /api/health is public', () => {
|
||||||
|
expect(isPublicApiPath('GET', '/api/health')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/auth/app-config is public', () => {
|
||||||
|
expect(isPublicApiPath('GET', '/api/auth/app-config')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/auth/login is public', () => {
|
||||||
|
expect(isPublicApiPath('POST', '/api/auth/login')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/auth/register is public', () => {
|
||||||
|
expect(isPublicApiPath('POST', '/api/auth/register')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/auth/demo-login is public', () => {
|
||||||
|
expect(isPublicApiPath('POST', '/api/auth/demo-login')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/auth/invite/<token> is public', () => {
|
||||||
|
expect(isPublicApiPath('GET', '/api/auth/invite/abc123')).toBe(true);
|
||||||
|
expect(isPublicApiPath('GET', '/api/auth/invite/xyz-789')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/auth/mfa/verify-login is public', () => {
|
||||||
|
expect(isPublicApiPath('POST', '/api/auth/mfa/verify-login')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC paths are public (any method)', () => {
|
||||||
|
expect(isPublicApiPath('GET', '/api/auth/oidc/callback')).toBe(true);
|
||||||
|
expect(isPublicApiPath('POST', '/api/auth/oidc/login')).toBe(true);
|
||||||
|
expect(isPublicApiPath('GET', '/api/auth/oidc/discovery')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/trips is not public', () => {
|
||||||
|
expect(isPublicApiPath('GET', '/api/trips')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/auth/login with wrong method (GET) is not public', () => {
|
||||||
|
expect(isPublicApiPath('GET', '/api/auth/login')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/auth/me is not public', () => {
|
||||||
|
expect(isPublicApiPath('GET', '/api/auth/me')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/auth/logout is not public', () => {
|
||||||
|
expect(isPublicApiPath('DELETE', '/api/auth/logout')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── isMfaSetupExemptPath ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('isMfaSetupExemptPath', () => {
|
||||||
|
it('GET /api/auth/me is MFA-setup exempt', () => {
|
||||||
|
expect(isMfaSetupExemptPath('GET', '/api/auth/me')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/auth/mfa/setup is MFA-setup exempt', () => {
|
||||||
|
expect(isMfaSetupExemptPath('POST', '/api/auth/mfa/setup')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/auth/mfa/enable is MFA-setup exempt', () => {
|
||||||
|
expect(isMfaSetupExemptPath('POST', '/api/auth/mfa/enable')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/auth/app-settings is MFA-setup exempt', () => {
|
||||||
|
expect(isMfaSetupExemptPath('GET', '/api/auth/app-settings')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/auth/app-settings is MFA-setup exempt', () => {
|
||||||
|
expect(isMfaSetupExemptPath('PUT', '/api/auth/app-settings')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/auth/app-settings is NOT exempt (wrong method)', () => {
|
||||||
|
expect(isMfaSetupExemptPath('POST', '/api/auth/app-settings')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/trips is NOT exempt', () => {
|
||||||
|
expect(isMfaSetupExemptPath('GET', '/api/trips')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/auth/logout is NOT exempt', () => {
|
||||||
|
expect(isMfaSetupExemptPath('GET', '/api/auth/logout')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
109
server/tests/unit/middleware/validate.test.ts
Normal file
109
server/tests/unit/middleware/validate.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { maxLength, validateStringLengths } from '../../../src/middleware/validate';
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
function makeReq(body: Record<string, unknown> = {}): Request {
|
||||||
|
return { body } as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRes(): { res: Response; status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn> } {
|
||||||
|
const json = vi.fn();
|
||||||
|
const status = vi.fn(() => ({ json }));
|
||||||
|
const res = { status } as unknown as Response;
|
||||||
|
return { res, status, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── maxLength ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('maxLength', () => {
|
||||||
|
it('calls next() when field is absent from body', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res } = makeRes();
|
||||||
|
maxLength('name', 10)(makeReq({}), res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls next() when field is not a string (number)', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res } = makeRes();
|
||||||
|
maxLength('count', 5)(makeReq({ count: 999 }), res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls next() when string length is within limit', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res } = makeRes();
|
||||||
|
maxLength('name', 10)(makeReq({ name: 'hello' }), res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls next() when string length equals max exactly', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res } = makeRes();
|
||||||
|
maxLength('name', 5)(makeReq({ name: 'hello' }), res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when field exceeds max', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res, status, json } = makeRes();
|
||||||
|
maxLength('name', 4)(makeReq({ name: 'hello' }), res, next);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(status).toHaveBeenCalledWith(400);
|
||||||
|
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringContaining('name') }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error message includes field name and max length', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res, json } = makeRes();
|
||||||
|
maxLength('title', 3)(makeReq({ title: 'toolong' }), res, next);
|
||||||
|
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringMatching(/title.*3|3.*title/i) }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── validateStringLengths ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('validateStringLengths', () => {
|
||||||
|
it('calls next() when all fields are within limits', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res } = makeRes();
|
||||||
|
validateStringLengths({ name: 10, bio: 100 })(makeReq({ name: 'Alice', bio: 'A short bio' }), res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 on first field that exceeds its limit', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res, status } = makeRes();
|
||||||
|
validateStringLengths({ name: 3 })(makeReq({ name: 'toolong' }), res, next);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(status).toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips fields not present in body', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res } = makeRes();
|
||||||
|
validateStringLengths({ name: 10, missing: 5 })(makeReq({ name: 'Alice' }), res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips non-string fields', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res } = makeRes();
|
||||||
|
validateStringLengths({ count: 5 })(makeReq({ count: 999999 }), res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty maxLengths object — calls next()', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res } = makeRes();
|
||||||
|
validateStringLengths({})(makeReq({ anything: 'value' }), res, next);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls next() only once even if multiple fields are valid', () => {
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const { res } = makeRes();
|
||||||
|
validateStringLengths({ a: 10, b: 10 })(makeReq({ a: 'ok', b: 'ok' }), res, next);
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
132
server/tests/unit/scheduler.test.ts
Normal file
132
server/tests/unit/scheduler.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Prevent node-cron from scheduling anything at import time
|
||||||
|
vi.mock('node-cron', () => ({
|
||||||
|
default: { schedule: vi.fn(), validate: vi.fn(() => true) },
|
||||||
|
schedule: vi.fn(),
|
||||||
|
validate: vi.fn(() => true),
|
||||||
|
}));
|
||||||
|
// Prevent archiver from causing side effects
|
||||||
|
vi.mock('archiver', () => ({ default: vi.fn() }));
|
||||||
|
// Prevent fs side effects (creating directories, reading files)
|
||||||
|
vi.mock('node:fs', () => ({
|
||||||
|
default: {
|
||||||
|
existsSync: vi.fn(() => false),
|
||||||
|
mkdirSync: vi.fn(),
|
||||||
|
readFileSync: vi.fn(() => '{}'),
|
||||||
|
writeFileSync: vi.fn(),
|
||||||
|
readdirSync: vi.fn(() => []),
|
||||||
|
statSync: vi.fn(() => ({ mtime: new Date(), size: 0 })),
|
||||||
|
createWriteStream: vi.fn(() => ({ on: vi.fn(), pipe: vi.fn() })),
|
||||||
|
},
|
||||||
|
existsSync: vi.fn(() => false),
|
||||||
|
mkdirSync: vi.fn(),
|
||||||
|
readFileSync: vi.fn(() => '{}'),
|
||||||
|
writeFileSync: vi.fn(),
|
||||||
|
readdirSync: vi.fn(() => []),
|
||||||
|
statSync: vi.fn(() => ({ mtime: new Date(), size: 0 })),
|
||||||
|
createWriteStream: vi.fn(() => ({ on: vi.fn(), pipe: vi.fn() })),
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/db/database', () => ({
|
||||||
|
db: { prepare: () => ({ all: vi.fn(() => []), get: vi.fn(), run: vi.fn() }) },
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||||
|
|
||||||
|
import { buildCronExpression } from '../../src/scheduler';
|
||||||
|
|
||||||
|
interface BackupSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
interval: string;
|
||||||
|
keep_days: number;
|
||||||
|
hour: number;
|
||||||
|
day_of_week: number;
|
||||||
|
day_of_month: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settings(overrides: Partial<BackupSettings> = {}): BackupSettings {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
interval: 'daily',
|
||||||
|
keep_days: 7,
|
||||||
|
hour: 2,
|
||||||
|
day_of_week: 0,
|
||||||
|
day_of_month: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildCronExpression', () => {
|
||||||
|
describe('hourly', () => {
|
||||||
|
it('returns 0 * * * * regardless of hour/dow/dom', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'hourly', hour: 5, day_of_week: 3, day_of_month: 15 }))).toBe('0 * * * *');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('daily', () => {
|
||||||
|
it('returns 0 <hour> * * *', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'daily', hour: 3 }))).toBe('0 3 * * *');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles midnight (hour 0)', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'daily', hour: 0 }))).toBe('0 0 * * *');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles last valid hour (23)', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'daily', hour: 23 }))).toBe('0 23 * * *');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to hour 2 for invalid hour (24)', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'daily', hour: 24 }))).toBe('0 2 * * *');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to hour 2 for negative hour', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'daily', hour: -1 }))).toBe('0 2 * * *');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('weekly', () => {
|
||||||
|
it('returns 0 <hour> * * <dow>', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'weekly', hour: 5, day_of_week: 3 }))).toBe('0 5 * * 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Sunday (dow 0)', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'weekly', hour: 2, day_of_week: 0 }))).toBe('0 2 * * 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Saturday (dow 6)', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'weekly', hour: 2, day_of_week: 6 }))).toBe('0 2 * * 6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to dow 0 for invalid day_of_week (7)', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'weekly', hour: 2, day_of_week: 7 }))).toBe('0 2 * * 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('monthly', () => {
|
||||||
|
it('returns 0 <hour> <dom> * *', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'monthly', hour: 2, day_of_month: 15 }))).toBe('0 2 15 * *');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles day_of_month 1', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'monthly', hour: 2, day_of_month: 1 }))).toBe('0 2 1 * *');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles max valid day_of_month (28)', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'monthly', hour: 2, day_of_month: 28 }))).toBe('0 2 28 * *');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to dom 1 for day_of_month 29', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'monthly', hour: 2, day_of_month: 29 }))).toBe('0 2 1 * *');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to dom 1 for day_of_month 0', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'monthly', hour: 2, day_of_month: 0 }))).toBe('0 2 1 * *');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unknown interval', () => {
|
||||||
|
it('defaults to daily pattern', () => {
|
||||||
|
expect(buildCronExpression(settings({ interval: 'unknown', hour: 4 }))).toBe('0 4 * * *');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
79
server/tests/unit/services/apiKeyCrypto.test.ts
Normal file
79
server/tests/unit/services/apiKeyCrypto.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Inline factory to avoid vi.mock hoisting issue (no imported vars allowed)
|
||||||
|
vi.mock('../../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { encrypt_api_key, decrypt_api_key, maybe_encrypt_api_key } from '../../../src/services/apiKeyCrypto';
|
||||||
|
|
||||||
|
describe('apiKeyCrypto', () => {
|
||||||
|
const PLAINTEXT_KEY = 'my-secret-api-key-12345';
|
||||||
|
const ENC_PREFIX = 'enc:v1:';
|
||||||
|
|
||||||
|
// SEC-008 — Encrypted API keys not returned in plaintext
|
||||||
|
describe('encrypt_api_key', () => {
|
||||||
|
it('SEC-008: returns encrypted string with enc:v1: prefix', () => {
|
||||||
|
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||||
|
expect(encrypted).toMatch(/^enc:v1:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different calls produce different ciphertext (random IV)', () => {
|
||||||
|
const enc1 = encrypt_api_key(PLAINTEXT_KEY);
|
||||||
|
const enc2 = encrypt_api_key(PLAINTEXT_KEY);
|
||||||
|
expect(enc1).not.toBe(enc2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encrypted value does not contain the plaintext', () => {
|
||||||
|
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||||
|
expect(encrypted).not.toContain(PLAINTEXT_KEY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decrypt_api_key', () => {
|
||||||
|
it('SEC-008: decrypts an encrypted key back to original', () => {
|
||||||
|
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||||
|
const decrypted = decrypt_api_key(encrypted);
|
||||||
|
expect(decrypted).toBe(PLAINTEXT_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for null input', () => {
|
||||||
|
expect(decrypt_api_key(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty string', () => {
|
||||||
|
expect(decrypt_api_key('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns plaintext as-is if not prefixed (legacy)', () => {
|
||||||
|
expect(decrypt_api_key('plain-legacy-key')).toBe('plain-legacy-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for tampered ciphertext', () => {
|
||||||
|
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||||
|
const tampered = encrypted.replace(ENC_PREFIX, ENC_PREFIX) + 'TAMPER';
|
||||||
|
expect(decrypt_api_key(tampered)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maybe_encrypt_api_key', () => {
|
||||||
|
it('encrypts a new plaintext value', () => {
|
||||||
|
const result = maybe_encrypt_api_key('my-key');
|
||||||
|
expect(result).toMatch(/^enc:v1:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty/falsy values', () => {
|
||||||
|
expect(maybe_encrypt_api_key('')).toBeNull();
|
||||||
|
expect(maybe_encrypt_api_key(null)).toBeNull();
|
||||||
|
expect(maybe_encrypt_api_key(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns already-encrypted value as-is (no double-encryption)', () => {
|
||||||
|
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||||
|
const result = maybe_encrypt_api_key(encrypted);
|
||||||
|
expect(result).toBe(encrypted);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
70
server/tests/unit/services/auditLog.test.ts
Normal file
70
server/tests/unit/services/auditLog.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Prevent file I/O side effects at module load time
|
||||||
|
vi.mock('fs', () => ({
|
||||||
|
default: {
|
||||||
|
mkdirSync: vi.fn(),
|
||||||
|
existsSync: vi.fn(() => false),
|
||||||
|
statSync: vi.fn(() => ({ size: 0 })),
|
||||||
|
appendFileSync: vi.fn(),
|
||||||
|
renameSync: vi.fn(),
|
||||||
|
},
|
||||||
|
mkdirSync: vi.fn(),
|
||||||
|
existsSync: vi.fn(() => false),
|
||||||
|
statSync: vi.fn(() => ({ size: 0 })),
|
||||||
|
appendFileSync: vi.fn(),
|
||||||
|
renameSync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => ({
|
||||||
|
db: { prepare: () => ({ get: vi.fn(), run: vi.fn() }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getClientIp } from '../../../src/services/auditLog';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
function makeReq(options: {
|
||||||
|
xff?: string | string[];
|
||||||
|
remoteAddress?: string;
|
||||||
|
} = {}): Request {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
...(options.xff !== undefined ? { 'x-forwarded-for': options.xff } : {}),
|
||||||
|
},
|
||||||
|
socket: { remoteAddress: options.remoteAddress ?? undefined },
|
||||||
|
} as unknown as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getClientIp', () => {
|
||||||
|
it('returns first IP from comma-separated X-Forwarded-For string', () => {
|
||||||
|
expect(getClientIp(makeReq({ xff: '1.2.3.4, 5.6.7.8, 9.10.11.12' }))).toBe('1.2.3.4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns single IP when X-Forwarded-For has no comma', () => {
|
||||||
|
expect(getClientIp(makeReq({ xff: '10.0.0.1' }))).toBe('10.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns first element when X-Forwarded-For is an array', () => {
|
||||||
|
expect(getClientIp(makeReq({ xff: ['203.0.113.1', '10.0.0.1'] }))).toBe('203.0.113.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace from extracted IP', () => {
|
||||||
|
expect(getClientIp(makeReq({ xff: ' 192.168.1.1 , 10.0.0.1' }))).toBe('192.168.1.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to req.socket.remoteAddress when no X-Forwarded-For', () => {
|
||||||
|
expect(getClientIp(makeReq({ remoteAddress: '172.16.0.1' }))).toBe('172.16.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no forwarded header and no socket address', () => {
|
||||||
|
expect(getClientIp(makeReq({}))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty string X-Forwarded-For', () => {
|
||||||
|
const req = {
|
||||||
|
headers: { 'x-forwarded-for': '' },
|
||||||
|
socket: { remoteAddress: undefined },
|
||||||
|
} as unknown as Request;
|
||||||
|
expect(getClientIp(req)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
299
server/tests/unit/services/authService.test.ts
Normal file
299
server/tests/unit/services/authService.test.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => ({
|
||||||
|
db: { prepare: () => ({ get: vi.fn(), all: vi.fn(), run: vi.fn() }) },
|
||||||
|
canAccessTrip: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||||
|
vi.mock('../../../src/services/mfaCrypto', () => ({ encryptMfaSecret: vi.fn(), decryptMfaSecret: vi.fn() }));
|
||||||
|
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||||
|
decrypt_api_key: vi.fn((v) => v),
|
||||||
|
maybe_encrypt_api_key: vi.fn((v) => v),
|
||||||
|
encrypt_api_key: vi.fn((v) => v),
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/services/permissions', () => ({ getAllPermissions: vi.fn(() => ({})), checkPermission: vi.fn() }));
|
||||||
|
vi.mock('../../../src/services/ephemeralTokens', () => ({ createEphemeralToken: vi.fn() }));
|
||||||
|
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
|
||||||
|
vi.mock('../../../src/scheduler', () => ({ startTripReminders: vi.fn(), buildCronExpression: vi.fn() }));
|
||||||
|
|
||||||
|
import {
|
||||||
|
utcSuffix,
|
||||||
|
stripUserForClient,
|
||||||
|
maskKey,
|
||||||
|
avatarUrl,
|
||||||
|
normalizeBackupCode,
|
||||||
|
hashBackupCode,
|
||||||
|
generateBackupCodes,
|
||||||
|
parseBackupCodeHashes,
|
||||||
|
} from '../../../src/services/authService';
|
||||||
|
import type { User } from '../../../src/types';
|
||||||
|
|
||||||
|
// ── utcSuffix ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('utcSuffix', () => {
|
||||||
|
it('returns null for null', () => {
|
||||||
|
expect(utcSuffix(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for undefined', () => {
|
||||||
|
expect(utcSuffix(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty string', () => {
|
||||||
|
expect(utcSuffix('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns timestamp unchanged when already ending with Z', () => {
|
||||||
|
expect(utcSuffix('2024-01-01T12:00:00Z')).toBe('2024-01-01T12:00:00Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces space with T and appends Z for SQLite-style datetime', () => {
|
||||||
|
expect(utcSuffix('2024-01-01 12:00:00')).toBe('2024-01-01T12:00:00Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends Z when T is present but Z is missing', () => {
|
||||||
|
expect(utcSuffix('2024-06-15T08:30:00')).toBe('2024-06-15T08:30:00Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── stripUserForClient ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeUser(overrides: Partial<User> = {}): User {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
username: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
role: 'user',
|
||||||
|
password_hash: 'supersecret',
|
||||||
|
maps_api_key: 'maps-key',
|
||||||
|
openweather_api_key: 'weather-key',
|
||||||
|
unsplash_api_key: 'unsplash-key',
|
||||||
|
mfa_secret: 'totpsecret',
|
||||||
|
mfa_backup_codes: '["hash1","hash2"]',
|
||||||
|
mfa_enabled: 0,
|
||||||
|
must_change_password: 0,
|
||||||
|
avatar: null,
|
||||||
|
created_at: '2024-01-01 00:00:00',
|
||||||
|
updated_at: '2024-06-01 00:00:00',
|
||||||
|
last_login: null,
|
||||||
|
...overrides,
|
||||||
|
} as unknown as User;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stripUserForClient', () => {
|
||||||
|
it('SEC-008: omits password_hash', () => {
|
||||||
|
const result = stripUserForClient(makeUser());
|
||||||
|
expect(result).not.toHaveProperty('password_hash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-008: omits maps_api_key', () => {
|
||||||
|
const result = stripUserForClient(makeUser());
|
||||||
|
expect(result).not.toHaveProperty('maps_api_key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-008: omits openweather_api_key', () => {
|
||||||
|
const result = stripUserForClient(makeUser());
|
||||||
|
expect(result).not.toHaveProperty('openweather_api_key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-008: omits unsplash_api_key', () => {
|
||||||
|
const result = stripUserForClient(makeUser());
|
||||||
|
expect(result).not.toHaveProperty('unsplash_api_key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-008: omits mfa_secret', () => {
|
||||||
|
const result = stripUserForClient(makeUser());
|
||||||
|
expect(result).not.toHaveProperty('mfa_secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-008: omits mfa_backup_codes', () => {
|
||||||
|
const result = stripUserForClient(makeUser());
|
||||||
|
expect(result).not.toHaveProperty('mfa_backup_codes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-sensitive fields', () => {
|
||||||
|
const result = stripUserForClient(makeUser({ username: 'alice', email: 'alice@example.com', role: 'user' }));
|
||||||
|
expect(result.id).toBe(1);
|
||||||
|
expect(result.username).toBe('alice');
|
||||||
|
expect(result.email).toBe('alice@example.com');
|
||||||
|
expect(result.role).toBe('user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes mfa_enabled integer 1 to true', () => {
|
||||||
|
const result = stripUserForClient(makeUser({ mfa_enabled: 1 } as any));
|
||||||
|
expect(result.mfa_enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes mfa_enabled integer 0 to false', () => {
|
||||||
|
const result = stripUserForClient(makeUser({ mfa_enabled: 0 } as any));
|
||||||
|
expect(result.mfa_enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes mfa_enabled boolean true to true', () => {
|
||||||
|
const result = stripUserForClient(makeUser({ mfa_enabled: true } as any));
|
||||||
|
expect(result.mfa_enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes must_change_password integer 1 to true', () => {
|
||||||
|
const result = stripUserForClient(makeUser({ must_change_password: 1 } as any));
|
||||||
|
expect(result.must_change_password).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes must_change_password integer 0 to false', () => {
|
||||||
|
const result = stripUserForClient(makeUser({ must_change_password: 0 } as any));
|
||||||
|
expect(result.must_change_password).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts created_at through utcSuffix', () => {
|
||||||
|
const result = stripUserForClient(makeUser({ created_at: '2024-01-01 00:00:00' }));
|
||||||
|
expect(result.created_at).toBe('2024-01-01T00:00:00Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts updated_at through utcSuffix', () => {
|
||||||
|
const result = stripUserForClient(makeUser({ updated_at: '2024-06-01 12:00:00' }));
|
||||||
|
expect(result.updated_at).toBe('2024-06-01T12:00:00Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes null last_login through as null', () => {
|
||||||
|
const result = stripUserForClient(makeUser({ last_login: null }));
|
||||||
|
expect(result.last_login).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── maskKey ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('maskKey', () => {
|
||||||
|
it('returns null for null', () => {
|
||||||
|
expect(maskKey(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for undefined', () => {
|
||||||
|
expect(maskKey(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty string', () => {
|
||||||
|
expect(maskKey('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns -------- for keys with 8 or fewer characters', () => {
|
||||||
|
expect(maskKey('abcd1234')).toBe('--------');
|
||||||
|
expect(maskKey('short')).toBe('--------');
|
||||||
|
expect(maskKey('a')).toBe('--------');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ---- + last 4 chars for keys longer than 8 characters', () => {
|
||||||
|
expect(maskKey('abcdefghijkl')).toBe('----ijkl');
|
||||||
|
expect(maskKey('sk-test-12345678')).toBe('----5678');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── avatarUrl ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('avatarUrl', () => {
|
||||||
|
it('returns /uploads/avatars/<filename> when avatar is set', () => {
|
||||||
|
expect(avatarUrl({ avatar: 'photo.jpg' })).toBe('/uploads/avatars/photo.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when avatar is null', () => {
|
||||||
|
expect(avatarUrl({ avatar: null })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when avatar is undefined', () => {
|
||||||
|
expect(avatarUrl({})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── normalizeBackupCode ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('normalizeBackupCode', () => {
|
||||||
|
it('uppercases the input', () => {
|
||||||
|
expect(normalizeBackupCode('abcd1234')).toBe('ABCD1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips non-alphanumeric characters', () => {
|
||||||
|
expect(normalizeBackupCode('AB-CD 12!34')).toBe('ABCD1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles code with dashes (normal backup code format)', () => {
|
||||||
|
expect(normalizeBackupCode('A1B2-C3D4')).toBe('A1B2C3D4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for empty input', () => {
|
||||||
|
expect(normalizeBackupCode('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── hashBackupCode ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('hashBackupCode', () => {
|
||||||
|
it('returns a 64-character hex string', () => {
|
||||||
|
const hash = hashBackupCode('A1B2-C3D4');
|
||||||
|
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic: same input always produces same output', () => {
|
||||||
|
expect(hashBackupCode('A1B2-C3D4')).toBe(hashBackupCode('A1B2-C3D4'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes before hashing: dashed and plain form produce the same hash', () => {
|
||||||
|
expect(hashBackupCode('A1B2-C3D4')).toBe(hashBackupCode('a1b2c3d4'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── generateBackupCodes ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('generateBackupCodes', () => {
|
||||||
|
it('returns 10 codes by default', () => {
|
||||||
|
const codes = generateBackupCodes();
|
||||||
|
expect(codes).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects a custom count', () => {
|
||||||
|
expect(generateBackupCodes(5)).toHaveLength(5);
|
||||||
|
expect(generateBackupCodes(20)).toHaveLength(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each code matches the XXXX-XXXX uppercase hex pattern', () => {
|
||||||
|
const codes = generateBackupCodes();
|
||||||
|
for (const code of codes) {
|
||||||
|
expect(code).toMatch(/^[0-9A-F]{4}-[0-9A-F]{4}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates no duplicate codes', () => {
|
||||||
|
const codes = generateBackupCodes(10);
|
||||||
|
expect(new Set(codes).size).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── parseBackupCodeHashes ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseBackupCodeHashes', () => {
|
||||||
|
it('returns [] for null', () => {
|
||||||
|
expect(parseBackupCodeHashes(null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for undefined', () => {
|
||||||
|
expect(parseBackupCodeHashes(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for empty string', () => {
|
||||||
|
expect(parseBackupCodeHashes('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for invalid JSON', () => {
|
||||||
|
expect(parseBackupCodeHashes('not-json')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for JSON that is not an array', () => {
|
||||||
|
expect(parseBackupCodeHashes('{"key":"value"}')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out non-string entries', () => {
|
||||||
|
expect(parseBackupCodeHashes('[1, "abc", null, true]')).toEqual(['abc']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all strings from a valid JSON string array', () => {
|
||||||
|
expect(parseBackupCodeHashes('["hash1","hash2","hash3"]')).toEqual(['hash1', 'hash2', 'hash3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
207
server/tests/unit/services/budgetService.test.ts
Normal file
207
server/tests/unit/services/budgetService.test.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// ── DB mock setup ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface MockPrepared {
|
||||||
|
all: ReturnType<typeof vi.fn>;
|
||||||
|
get: ReturnType<typeof vi.fn>;
|
||||||
|
run: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preparedMap: Record<string, MockPrepared> = {};
|
||||||
|
let defaultAll: ReturnType<typeof vi.fn>;
|
||||||
|
let defaultGet: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const mockDb = vi.hoisted(() => {
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
prepare: vi.fn((sql: string) => {
|
||||||
|
return {
|
||||||
|
all: vi.fn(() => []),
|
||||||
|
get: vi.fn(() => undefined),
|
||||||
|
run: vi.fn(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
canAccessTrip: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => mockDb);
|
||||||
|
|
||||||
|
import { calculateSettlement, avatarUrl } from '../../../src/services/budgetService';
|
||||||
|
import type { BudgetItem, BudgetItemMember } from '../../../src/types';
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeItem(id: number, total_price: number, trip_id = 1): BudgetItem {
|
||||||
|
return { id, trip_id, name: `Item ${id}`, total_price, category: 'Other' } as BudgetItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMember(budget_item_id: number, user_id: number, paid: boolean | 0 | 1, username: string): BudgetItemMember & { budget_item_id: number } {
|
||||||
|
return {
|
||||||
|
budget_item_id,
|
||||||
|
user_id,
|
||||||
|
paid: paid ? 1 : 0,
|
||||||
|
username,
|
||||||
|
avatar: null,
|
||||||
|
} as BudgetItemMember & { budget_item_id: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDb(items: BudgetItem[], members: (BudgetItemMember & { budget_item_id: number })[]) {
|
||||||
|
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||||
|
if (sql.includes('SELECT * FROM budget_items')) {
|
||||||
|
return { all: vi.fn(() => items), get: vi.fn(), run: vi.fn() };
|
||||||
|
}
|
||||||
|
if (sql.includes('budget_item_members')) {
|
||||||
|
return { all: vi.fn(() => members), get: vi.fn(), run: vi.fn() };
|
||||||
|
}
|
||||||
|
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setupDb([], []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── avatarUrl ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('avatarUrl', () => {
|
||||||
|
it('returns /uploads/avatars/<filename> when avatar is set', () => {
|
||||||
|
expect(avatarUrl({ avatar: 'photo.jpg' })).toBe('/uploads/avatars/photo.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when avatar is null', () => {
|
||||||
|
expect(avatarUrl({ avatar: null })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when avatar is undefined', () => {
|
||||||
|
expect(avatarUrl({})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── calculateSettlement ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('calculateSettlement', () => {
|
||||||
|
it('returns empty balances and flows when trip has no items', () => {
|
||||||
|
setupDb([], []);
|
||||||
|
const result = calculateSettlement(1);
|
||||||
|
expect(result.balances).toEqual([]);
|
||||||
|
expect(result.flows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no flows when there are items but no members', () => {
|
||||||
|
setupDb([makeItem(1, 100)], []);
|
||||||
|
const result = calculateSettlement(1);
|
||||||
|
expect(result.flows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no flows when no one is marked as paid', () => {
|
||||||
|
setupDb(
|
||||||
|
[makeItem(1, 100)],
|
||||||
|
[makeMember(1, 1, 0, 'alice'), makeMember(1, 2, 0, 'bob')],
|
||||||
|
);
|
||||||
|
const result = calculateSettlement(1);
|
||||||
|
expect(result.flows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2 members, 1 payer: payer is owed half, non-payer owes half', () => {
|
||||||
|
// Item: $100. Alice paid, Bob did not. Each owes $50. Alice net: +$50. Bob net: -$50.
|
||||||
|
setupDb(
|
||||||
|
[makeItem(1, 100)],
|
||||||
|
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')],
|
||||||
|
);
|
||||||
|
const result = calculateSettlement(1);
|
||||||
|
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||||
|
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||||
|
expect(alice.balance).toBe(50);
|
||||||
|
expect(bob.balance).toBe(-50);
|
||||||
|
expect(result.flows).toHaveLength(1);
|
||||||
|
expect(result.flows[0].from.user_id).toBe(2); // Bob owes
|
||||||
|
expect(result.flows[0].to.user_id).toBe(1); // Alice is owed
|
||||||
|
expect(result.flows[0].amount).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3 members, 1 payer: correct 3-way split', () => {
|
||||||
|
// Item: $90. Alice paid. Each of 3 owes $30. Alice net: +$60. Bob: -$30. Carol: -$30.
|
||||||
|
setupDb(
|
||||||
|
[makeItem(1, 90)],
|
||||||
|
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')],
|
||||||
|
);
|
||||||
|
const result = calculateSettlement(1);
|
||||||
|
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||||
|
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||||
|
const carol = result.balances.find(b => b.user_id === 3)!;
|
||||||
|
expect(alice.balance).toBe(60);
|
||||||
|
expect(bob.balance).toBe(-30);
|
||||||
|
expect(carol.balance).toBe(-30);
|
||||||
|
expect(result.flows).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all paid equally: all balances are zero, no flows', () => {
|
||||||
|
// Item: $60. 3 members, all paid equally (each paid $20, each owes $20). Net: 0.
|
||||||
|
// Actually with "paid" flag it means: paidPerPayer = item.total / numPayers.
|
||||||
|
// If all 3 paid: each gets +20 credit, each owes -20 = net 0 for everyone.
|
||||||
|
setupDb(
|
||||||
|
[makeItem(1, 60)],
|
||||||
|
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 1, 'bob'), makeMember(1, 3, 1, 'carol')],
|
||||||
|
);
|
||||||
|
const result = calculateSettlement(1);
|
||||||
|
for (const b of result.balances) {
|
||||||
|
expect(Math.abs(b.balance)).toBeLessThanOrEqual(0.01);
|
||||||
|
}
|
||||||
|
expect(result.flows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flow direction: from is debtor (owes), to is creditor (is owed)', () => {
|
||||||
|
// Alice paid $100 for 2 people. Bob owes Alice $50.
|
||||||
|
setupDb(
|
||||||
|
[makeItem(1, 100)],
|
||||||
|
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')],
|
||||||
|
);
|
||||||
|
const result = calculateSettlement(1);
|
||||||
|
const flow = result.flows[0];
|
||||||
|
expect(flow.from.username).toBe('bob'); // debtor
|
||||||
|
expect(flow.to.username).toBe('alice'); // creditor
|
||||||
|
});
|
||||||
|
|
||||||
|
it('amounts are rounded to 2 decimal places', () => {
|
||||||
|
// Item: $10. 3 members, 1 payer. Share = 3.333... Each rounded to 3.33.
|
||||||
|
setupDb(
|
||||||
|
[makeItem(1, 10)],
|
||||||
|
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')],
|
||||||
|
);
|
||||||
|
const result = calculateSettlement(1);
|
||||||
|
for (const b of result.balances) {
|
||||||
|
const str = b.balance.toString();
|
||||||
|
const decimals = str.includes('.') ? str.split('.')[1].length : 0;
|
||||||
|
expect(decimals).toBeLessThanOrEqual(2);
|
||||||
|
}
|
||||||
|
for (const flow of result.flows) {
|
||||||
|
const str = flow.amount.toString();
|
||||||
|
const decimals = str.includes('.') ? str.split('.')[1].length : 0;
|
||||||
|
expect(decimals).toBeLessThanOrEqual(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2 items with different payers: aggregates balances correctly', () => {
|
||||||
|
// Item 1: $100, Alice paid, [Alice, Bob] (Alice net: +50, Bob: -50)
|
||||||
|
// Item 2: $60, Bob paid, [Alice, Bob] (Bob net: +30, Alice: -30)
|
||||||
|
// Final: Alice: +50 - 30 = +20, Bob: -50 + 30 = -20
|
||||||
|
setupDb(
|
||||||
|
[makeItem(1, 100), makeItem(2, 60)],
|
||||||
|
[
|
||||||
|
makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'),
|
||||||
|
makeMember(2, 1, 0, 'alice'), makeMember(2, 2, 1, 'bob'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const result = calculateSettlement(1);
|
||||||
|
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||||
|
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||||
|
expect(alice.balance).toBe(20);
|
||||||
|
expect(bob.balance).toBe(-20);
|
||||||
|
expect(result.flows).toHaveLength(1);
|
||||||
|
expect(result.flows[0].amount).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
server/tests/unit/services/cookie.test.ts
Normal file
56
server/tests/unit/services/cookie.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
import { cookieOptions } from '../../../src/services/cookie';
|
||||||
|
|
||||||
|
describe('cookieOptions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always sets httpOnly: true', () => {
|
||||||
|
expect(cookieOptions()).toHaveProperty('httpOnly', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always sets sameSite: strict', () => {
|
||||||
|
expect(cookieOptions()).toHaveProperty('sameSite', 'strict');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always sets path: /', () => {
|
||||||
|
expect(cookieOptions()).toHaveProperty('path', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets secure: false in test environment (COOKIE_SECURE=false from setup)', () => {
|
||||||
|
// setup.ts sets COOKIE_SECURE=false, so secure should be false
|
||||||
|
const opts = cookieOptions();
|
||||||
|
expect(opts.secure).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets secure: true when NODE_ENV=production and COOKIE_SECURE is not false', () => {
|
||||||
|
vi.stubEnv('COOKIE_SECURE', 'true');
|
||||||
|
vi.stubEnv('NODE_ENV', 'production');
|
||||||
|
expect(cookieOptions().secure).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets secure: false when COOKIE_SECURE=false even in production', () => {
|
||||||
|
vi.stubEnv('COOKIE_SECURE', 'false');
|
||||||
|
vi.stubEnv('NODE_ENV', 'production');
|
||||||
|
expect(cookieOptions().secure).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets secure: true when FORCE_HTTPS=true', () => {
|
||||||
|
vi.stubEnv('COOKIE_SECURE', 'true');
|
||||||
|
vi.stubEnv('FORCE_HTTPS', 'true');
|
||||||
|
vi.stubEnv('NODE_ENV', 'development');
|
||||||
|
expect(cookieOptions().secure).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes maxAge: 86400000 when clear is false (default)', () => {
|
||||||
|
expect(cookieOptions()).toHaveProperty('maxAge', 24 * 60 * 60 * 1000);
|
||||||
|
expect(cookieOptions(false)).toHaveProperty('maxAge', 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits maxAge when clear is true', () => {
|
||||||
|
const opts = cookieOptions(true);
|
||||||
|
expect(opts).not.toHaveProperty('maxAge');
|
||||||
|
});
|
||||||
|
});
|
||||||
71
server/tests/unit/services/ephemeralTokens.test.ts
Normal file
71
server/tests/unit/services/ephemeralTokens.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Reset module between tests that need a fresh token store
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ephemeralTokens', () => {
|
||||||
|
async function getModule() {
|
||||||
|
return import('../../../src/services/ephemeralTokens');
|
||||||
|
}
|
||||||
|
|
||||||
|
// AUTH-030 — Resource token creation (single-use)
|
||||||
|
describe('createEphemeralToken', () => {
|
||||||
|
it('AUTH-030: creates a token and returns a hex string', async () => {
|
||||||
|
const { createEphemeralToken } = await getModule();
|
||||||
|
const token = createEphemeralToken(1, 'download');
|
||||||
|
expect(token).not.toBeNull();
|
||||||
|
expect(typeof token).toBe('string');
|
||||||
|
expect(token!.length).toBe(64); // 32 bytes hex
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-030: different calls produce different tokens', async () => {
|
||||||
|
const { createEphemeralToken } = await getModule();
|
||||||
|
const t1 = createEphemeralToken(1, 'download');
|
||||||
|
const t2 = createEphemeralToken(1, 'download');
|
||||||
|
expect(t1).not.toBe(t2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// AUTH-029 — WebSocket token expiry (single-use)
|
||||||
|
describe('consumeEphemeralToken', () => {
|
||||||
|
it('AUTH-030: token is consumed and returns userId on first use', async () => {
|
||||||
|
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
|
||||||
|
const token = createEphemeralToken(42, 'download')!;
|
||||||
|
const userId = consumeEphemeralToken(token, 'download');
|
||||||
|
expect(userId).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-030: token is single-use — second consume returns null', async () => {
|
||||||
|
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
|
||||||
|
const token = createEphemeralToken(42, 'download')!;
|
||||||
|
consumeEphemeralToken(token, 'download'); // first use
|
||||||
|
const second = consumeEphemeralToken(token, 'download'); // second use
|
||||||
|
expect(second).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-029: purpose mismatch returns null', async () => {
|
||||||
|
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
|
||||||
|
const token = createEphemeralToken(42, 'ws')!;
|
||||||
|
const result = consumeEphemeralToken(token, 'download');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-029: expired token returns null', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
|
||||||
|
const token = createEphemeralToken(42, 'ws')!; // 30s TTL
|
||||||
|
vi.advanceTimersByTime(31_000); // advance past expiry
|
||||||
|
const result = consumeEphemeralToken(token, 'ws');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown token', async () => {
|
||||||
|
const { consumeEphemeralToken } = await getModule();
|
||||||
|
const result = consumeEphemeralToken('nonexistent-token', 'download');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
58
server/tests/unit/services/mfaCrypto.test.ts
Normal file
58
server/tests/unit/services/mfaCrypto.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Inline factory to avoid vi.mock hoisting issue (no imported vars allowed)
|
||||||
|
vi.mock('../../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { encryptMfaSecret, decryptMfaSecret } from '../../../src/services/mfaCrypto';
|
||||||
|
|
||||||
|
describe('mfaCrypto', () => {
|
||||||
|
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP'; // typical base32 TOTP secret
|
||||||
|
|
||||||
|
// SEC-009 — Encrypted MFA secrets not exposed
|
||||||
|
describe('encryptMfaSecret', () => {
|
||||||
|
it('SEC-009: returns a base64 string (not the plaintext)', () => {
|
||||||
|
const encrypted = encryptMfaSecret(TOTP_SECRET);
|
||||||
|
expect(encrypted).not.toBe(TOTP_SECRET);
|
||||||
|
// Should be valid base64
|
||||||
|
expect(() => Buffer.from(encrypted, 'base64')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different calls produce different ciphertext (random IV)', () => {
|
||||||
|
const enc1 = encryptMfaSecret(TOTP_SECRET);
|
||||||
|
const enc2 = encryptMfaSecret(TOTP_SECRET);
|
||||||
|
expect(enc1).not.toBe(enc2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encrypted value does not contain plaintext', () => {
|
||||||
|
const encrypted = encryptMfaSecret(TOTP_SECRET);
|
||||||
|
expect(encrypted).not.toContain(TOTP_SECRET);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decryptMfaSecret', () => {
|
||||||
|
it('SEC-009: roundtrip — decrypt returns original secret', () => {
|
||||||
|
const encrypted = encryptMfaSecret(TOTP_SECRET);
|
||||||
|
const decrypted = decryptMfaSecret(encrypted);
|
||||||
|
expect(decrypted).toBe(TOTP_SECRET);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles secrets of varying lengths', () => {
|
||||||
|
const short = 'ABC123';
|
||||||
|
const long = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP';
|
||||||
|
expect(decryptMfaSecret(encryptMfaSecret(short))).toBe(short);
|
||||||
|
expect(decryptMfaSecret(encryptMfaSecret(long))).toBe(long);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws or returns garbage on tampered ciphertext', () => {
|
||||||
|
const encrypted = encryptMfaSecret(TOTP_SECRET);
|
||||||
|
const buf = Buffer.from(encrypted, 'base64');
|
||||||
|
buf[buf.length - 1] ^= 0xff; // flip last byte
|
||||||
|
const tampered = buf.toString('base64');
|
||||||
|
expect(() => decryptMfaSecret(tampered)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
195
server/tests/unit/services/notifications.test.ts
Normal file
195
server/tests/unit/services/notifications.test.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => ({
|
||||||
|
db: { prepare: () => ({ get: vi.fn(() => undefined), all: vi.fn(() => []) }) },
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||||
|
decrypt_api_key: vi.fn((v) => v),
|
||||||
|
maybe_encrypt_api_key: vi.fn((v) => v),
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/services/auditLog', () => ({
|
||||||
|
logInfo: vi.fn(),
|
||||||
|
logDebug: vi.fn(),
|
||||||
|
logError: vi.fn(),
|
||||||
|
logWarn: vi.fn(),
|
||||||
|
writeAudit: vi.fn(),
|
||||||
|
getClientIp: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn() })) } }));
|
||||||
|
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
||||||
|
|
||||||
|
import { getEventText, buildEmailHtml, buildWebhookBody } from '../../../src/services/notifications';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getEventText ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getEventText', () => {
|
||||||
|
const params = {
|
||||||
|
trip: 'Tokyo Adventure',
|
||||||
|
actor: 'Alice',
|
||||||
|
invitee: 'Bob',
|
||||||
|
booking: 'Hotel Sakura',
|
||||||
|
type: 'hotel',
|
||||||
|
count: '5',
|
||||||
|
preview: 'See you there!',
|
||||||
|
category: 'Clothing',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns English title and body for lang=en', () => {
|
||||||
|
const result = getEventText('en', 'trip_invite', params);
|
||||||
|
expect(result.title).toBeTruthy();
|
||||||
|
expect(result.body).toBeTruthy();
|
||||||
|
expect(result.title).toContain('Tokyo Adventure');
|
||||||
|
expect(result.body).toContain('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns German text for lang=de', () => {
|
||||||
|
const result = getEventText('de', 'trip_invite', params);
|
||||||
|
expect(result.title).toContain('Tokyo Adventure');
|
||||||
|
// German version uses "Einladung"
|
||||||
|
expect(result.title).toContain('Einladung');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to English for unknown language code', () => {
|
||||||
|
const en = getEventText('en', 'trip_invite', params);
|
||||||
|
const unknown = getEventText('xx', 'trip_invite', params);
|
||||||
|
expect(unknown.title).toBe(en.title);
|
||||||
|
expect(unknown.body).toBe(en.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interpolates params into trip_invite correctly', () => {
|
||||||
|
const result = getEventText('en', 'trip_invite', params);
|
||||||
|
expect(result.title).toContain('Tokyo Adventure');
|
||||||
|
expect(result.body).toContain('Alice');
|
||||||
|
expect(result.body).toContain('Bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all 7 event types produce non-empty title and body in English', () => {
|
||||||
|
const events = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'] as const;
|
||||||
|
for (const event of events) {
|
||||||
|
const result = getEventText('en', event, params);
|
||||||
|
expect(result.title, `title for ${event}`).toBeTruthy();
|
||||||
|
expect(result.body, `body for ${event}`).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all 7 event types produce non-empty title and body in German', () => {
|
||||||
|
const events = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'] as const;
|
||||||
|
for (const event of events) {
|
||||||
|
const result = getEventText('de', event, params);
|
||||||
|
expect(result.title, `de title for ${event}`).toBeTruthy();
|
||||||
|
expect(result.body, `de body for ${event}`).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── buildWebhookBody ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('buildWebhookBody', () => {
|
||||||
|
const payload = {
|
||||||
|
event: 'trip_invite',
|
||||||
|
title: 'Trip Invite',
|
||||||
|
body: 'Alice invited you',
|
||||||
|
tripName: 'Tokyo Adventure',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('Discord URL produces embeds array format', () => {
|
||||||
|
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', payload));
|
||||||
|
expect(body).toHaveProperty('embeds');
|
||||||
|
expect(Array.isArray(body.embeds)).toBe(true);
|
||||||
|
expect(body.embeds[0]).toHaveProperty('title');
|
||||||
|
expect(body.embeds[0]).toHaveProperty('description', payload.body);
|
||||||
|
expect(body.embeds[0]).toHaveProperty('color');
|
||||||
|
expect(body.embeds[0]).toHaveProperty('footer');
|
||||||
|
expect(body.embeds[0]).toHaveProperty('timestamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Discord embed title is prefixed with compass emoji', () => {
|
||||||
|
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', payload));
|
||||||
|
expect(body.embeds[0].title).toContain('📍');
|
||||||
|
expect(body.embeds[0].title).toContain(payload.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Discord embed footer contains trip name when provided', () => {
|
||||||
|
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', payload));
|
||||||
|
expect(body.embeds[0].footer.text).toContain('Tokyo Adventure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Discord embed footer defaults to TREK when no trip name', () => {
|
||||||
|
const noTrip = { ...payload, tripName: undefined };
|
||||||
|
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', noTrip));
|
||||||
|
expect(body.embeds[0].footer.text).toBe('TREK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discordapp.com URL is also detected as Discord', () => {
|
||||||
|
const body = JSON.parse(buildWebhookBody('https://discordapp.com/api/webhooks/123/abc', payload));
|
||||||
|
expect(body).toHaveProperty('embeds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Slack URL produces text field format', () => {
|
||||||
|
const body = JSON.parse(buildWebhookBody('https://hooks.slack.com/services/X/Y/Z', payload));
|
||||||
|
expect(body).toHaveProperty('text');
|
||||||
|
expect(body.text).toContain(payload.title);
|
||||||
|
expect(body.text).toContain(payload.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Slack text includes italic trip name when provided', () => {
|
||||||
|
const body = JSON.parse(buildWebhookBody('https://hooks.slack.com/services/X/Y/Z', payload));
|
||||||
|
expect(body.text).toContain('Tokyo Adventure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Slack text omits trip name when not provided', () => {
|
||||||
|
const noTrip = { ...payload, tripName: undefined };
|
||||||
|
const body = JSON.parse(buildWebhookBody('https://hooks.slack.com/services/X/Y/Z', noTrip));
|
||||||
|
// Should not contain the trip name string
|
||||||
|
expect(body.text).not.toContain('Tokyo Adventure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generic URL produces plain JSON with original fields plus timestamp and source', () => {
|
||||||
|
const body = JSON.parse(buildWebhookBody('https://mywebhook.example.com/hook', payload));
|
||||||
|
expect(body).toHaveProperty('event', payload.event);
|
||||||
|
expect(body).toHaveProperty('title', payload.title);
|
||||||
|
expect(body).toHaveProperty('body', payload.body);
|
||||||
|
expect(body).toHaveProperty('timestamp');
|
||||||
|
expect(body).toHaveProperty('source', 'TREK');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── buildEmailHtml ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('buildEmailHtml', () => {
|
||||||
|
it('returns a string containing <!DOCTYPE html>', () => {
|
||||||
|
const html = buildEmailHtml('Test Subject', 'Test body text', 'en');
|
||||||
|
expect(html).toContain('<!DOCTYPE html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains the subject text', () => {
|
||||||
|
const html = buildEmailHtml('My Email Subject', 'Some body', 'en');
|
||||||
|
expect(html).toContain('My Email Subject');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains the body text', () => {
|
||||||
|
const html = buildEmailHtml('Subject', 'Hello world, this is the body!', 'en');
|
||||||
|
expect(html).toContain('Hello world, this is the body!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses English i18n strings for lang=en', () => {
|
||||||
|
const html = buildEmailHtml('Subject', 'Body', 'en');
|
||||||
|
expect(html).toContain('notifications enabled in TREK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses German i18n strings for lang=de', () => {
|
||||||
|
const html = buildEmailHtml('Subject', 'Body', 'de');
|
||||||
|
expect(html).toContain('TREK aktiviert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to English i18n for unknown language', () => {
|
||||||
|
const en = buildEmailHtml('Subject', 'Body', 'en');
|
||||||
|
const unknown = buildEmailHtml('Subject', 'Body', 'xx');
|
||||||
|
// Both should have the same footer text
|
||||||
|
expect(unknown).toContain('notifications enabled in TREK');
|
||||||
|
});
|
||||||
|
});
|
||||||
101
server/tests/unit/services/passwordPolicy.test.ts
Normal file
101
server/tests/unit/services/passwordPolicy.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { validatePassword } from '../../../src/services/passwordPolicy';
|
||||||
|
|
||||||
|
describe('validatePassword', () => {
|
||||||
|
// AUTH-006 — Registration with weak password
|
||||||
|
describe('length requirement', () => {
|
||||||
|
it('AUTH-006: rejects passwords shorter than 8 characters', () => {
|
||||||
|
expect(validatePassword('Ab1!')).toEqual({ ok: false, reason: expect.stringContaining('8 characters') });
|
||||||
|
expect(validatePassword('Ab1!456')).toEqual({ ok: false, reason: expect.stringContaining('8 characters') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts passwords of exactly 8 characters that meet all requirements', () => {
|
||||||
|
expect(validatePassword('Ab1!abcd')).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('complexity requirements', () => {
|
||||||
|
it('AUTH-006: rejects password missing uppercase letter', () => {
|
||||||
|
const result = validatePassword('abcd1234!');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toContain('uppercase');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-006: rejects password missing lowercase letter', () => {
|
||||||
|
const result = validatePassword('ABCD1234!');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toContain('lowercase');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-006: rejects password missing a number', () => {
|
||||||
|
const result = validatePassword('Abcdefg!');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toContain('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-006: rejects password missing a special character', () => {
|
||||||
|
// 'TrekApp1' — has upper, lower, number, NO special char, NOT in blocklist
|
||||||
|
const result = validatePassword('TrekApp1');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toContain('special character');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// AUTH-007 — Registration with common password
|
||||||
|
describe('common password blocklist', () => {
|
||||||
|
it('AUTH-007: rejects password matching exact blocklist entry (case-insensitive)', () => {
|
||||||
|
// 'password1' is in the blocklist. A capitalised+special variant still matches
|
||||||
|
// because the check is COMMON_PASSWORDS.has(password.toLowerCase()).
|
||||||
|
// However, 'Password1!' lowercased is 'password1!' which is NOT in the set.
|
||||||
|
// We must use a password whose lowercase is exactly in the set:
|
||||||
|
// 'Iloveyou1!' — lowercased: 'iloveyou1!' — NOT in set.
|
||||||
|
// Use a password whose *lowercase* IS in set: 'changeme' → 'Changeme' is 8 chars
|
||||||
|
// but lacks uppercase/number/special — test blocklist with full complex variants:
|
||||||
|
// 'ILoveyou1!' lowercased = 'iloveyou1!' — not in set.
|
||||||
|
// Just test exact matches that satisfy complexity: use blocklist entry itself.
|
||||||
|
// 'Iloveyou' is 8 chars, no number/special → fails complexity, not blocklist.
|
||||||
|
// Better: pick a blocklist entry that, when capitalised + special added, still matches.
|
||||||
|
// The check is: COMMON_PASSWORDS.has(password.toLowerCase())
|
||||||
|
// So 'FOOTBALL!' lowercased = 'football!' — not in set ('football' is in set).
|
||||||
|
// We need password.toLowerCase() to equal a set entry exactly:
|
||||||
|
// 'football' → add uppercase → 'Football' is still 8 chars, no number, no special → fails complexity first
|
||||||
|
// The blocklist check happens BEFORE complexity checks, after length + repetitive checks.
|
||||||
|
// So any 8+ char string whose lowercase is in the blocklist gets caught first.
|
||||||
|
// 'Password1' lowercased = 'password1' → in blocklist! ✓ (length ok, not repetitive)
|
||||||
|
expect(validatePassword('Password1')).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: expect.stringContaining('common'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-007: rejects "Changeme" whose lowercase is in the blocklist', () => {
|
||||||
|
// 'changeme' is in the set; 'Changeme'.toLowerCase() === 'changeme' ✓
|
||||||
|
expect(validatePassword('Changeme')).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: expect.stringContaining('common'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a strong password that is not in the blocklist', () => {
|
||||||
|
expect(validatePassword('MyUniq!1Trek')).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repetitive password', () => {
|
||||||
|
it('rejects passwords made of a single repeated character', () => {
|
||||||
|
const result = validatePassword('AAAAAAAA');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toContain('repetitive');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('valid passwords', () => {
|
||||||
|
it('accepts a strong unique password', () => {
|
||||||
|
expect(validatePassword('Tr3k!SecurePass')).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a strong password with special characters', () => {
|
||||||
|
expect(validatePassword('MyP@ss#2024')).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
83
server/tests/unit/services/permissions.test.ts
Normal file
83
server/tests/unit/services/permissions.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock database — permissions module queries app_settings at runtime
|
||||||
|
vi.mock('../../../src/db/database', () => ({
|
||||||
|
db: {
|
||||||
|
prepare: () => ({
|
||||||
|
all: () => [], // no custom permissions → fall back to defaults
|
||||||
|
run: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { checkPermission, getPermissionLevel, PERMISSION_ACTIONS } from '../../../src/services/permissions';
|
||||||
|
|
||||||
|
describe('permissions', () => {
|
||||||
|
describe('checkPermission — admin bypass', () => {
|
||||||
|
it('admin always passes regardless of permission level', () => {
|
||||||
|
for (const action of PERMISSION_ACTIONS) {
|
||||||
|
expect(checkPermission(action.key, 'admin', 1, 1, false)).toBe(true);
|
||||||
|
expect(checkPermission(action.key, 'admin', 99, 1, false)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkPermission — everybody level', () => {
|
||||||
|
it('trip_create (everybody) allows any authenticated user', () => {
|
||||||
|
expect(checkPermission('trip_create', 'user', null, 42, false)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkPermission — trip_owner level', () => {
|
||||||
|
const ownerId = 10;
|
||||||
|
const memberId = 20;
|
||||||
|
|
||||||
|
it('trip owner passes trip_owner check', () => {
|
||||||
|
expect(checkPermission('trip_delete', 'user', ownerId, ownerId, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('member fails trip_owner check', () => {
|
||||||
|
expect(checkPermission('trip_delete', 'user', ownerId, memberId, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-member non-owner fails trip_owner check', () => {
|
||||||
|
expect(checkPermission('trip_delete', 'user', ownerId, memberId, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkPermission — trip_member level', () => {
|
||||||
|
const ownerId = 10;
|
||||||
|
const memberId = 20;
|
||||||
|
const outsiderId = 30;
|
||||||
|
|
||||||
|
it('trip owner passes trip_member check', () => {
|
||||||
|
expect(checkPermission('day_edit', 'user', ownerId, ownerId, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trip member passes trip_member check', () => {
|
||||||
|
expect(checkPermission('day_edit', 'user', ownerId, memberId, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outsider fails trip_member check', () => {
|
||||||
|
expect(checkPermission('day_edit', 'user', ownerId, outsiderId, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPermissionLevel — defaults', () => {
|
||||||
|
it('returns default level for known actions (no DB overrides)', () => {
|
||||||
|
const defaults: Record<string, string> = {
|
||||||
|
trip_create: 'everybody',
|
||||||
|
trip_delete: 'trip_owner',
|
||||||
|
day_edit: 'trip_member',
|
||||||
|
budget_edit: 'trip_member',
|
||||||
|
};
|
||||||
|
for (const [key, expected] of Object.entries(defaults)) {
|
||||||
|
expect(getPermissionLevel(key)).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns trip_owner for unknown action key', () => {
|
||||||
|
expect(getPermissionLevel('nonexistent_action')).toBe('trip_owner');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
123
server/tests/unit/services/queryHelpers.test.ts
Normal file
123
server/tests/unit/services/queryHelpers.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => ({
|
||||||
|
db: { prepare: () => ({ all: () => [], get: vi.fn() }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { formatAssignmentWithPlace } from '../../../src/services/queryHelpers';
|
||||||
|
import type { AssignmentRow, Tag, Participant } from '../../../src/types';
|
||||||
|
|
||||||
|
function makeRow(overrides: Partial<AssignmentRow> = {}): AssignmentRow {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
day_id: 10,
|
||||||
|
place_id: 100,
|
||||||
|
order_index: 0,
|
||||||
|
notes: 'assignment note',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
place_name: 'Eiffel Tower',
|
||||||
|
place_description: 'Famous landmark',
|
||||||
|
lat: 48.8584,
|
||||||
|
lng: 2.2945,
|
||||||
|
address: 'Champ de Mars, Paris',
|
||||||
|
category_id: 5,
|
||||||
|
category_name: 'Sightseeing',
|
||||||
|
category_color: '#3b82f6',
|
||||||
|
category_icon: 'landmark',
|
||||||
|
price: 25.0,
|
||||||
|
place_currency: 'EUR',
|
||||||
|
place_time: '10:00',
|
||||||
|
end_time: '12:00',
|
||||||
|
duration_minutes: 120,
|
||||||
|
place_notes: 'Bring tickets',
|
||||||
|
image_url: 'https://example.com/img.jpg',
|
||||||
|
transport_mode: 'walk',
|
||||||
|
google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0',
|
||||||
|
website: 'https://eiffel-tower.com',
|
||||||
|
phone: '+33 1 2345 6789',
|
||||||
|
...overrides,
|
||||||
|
} as AssignmentRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleTags: Partial<Tag>[] = [
|
||||||
|
{ id: 1, name: 'Must-see', color: '#ef4444' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sampleParticipants: Participant[] = [
|
||||||
|
{ user_id: 42, username: 'alice', avatar: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('formatAssignmentWithPlace', () => {
|
||||||
|
it('returns correct top-level shape', () => {
|
||||||
|
const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants);
|
||||||
|
expect(result).toHaveProperty('id', 1);
|
||||||
|
expect(result).toHaveProperty('day_id', 10);
|
||||||
|
expect(result).toHaveProperty('order_index', 0);
|
||||||
|
expect(result).toHaveProperty('notes', 'assignment note');
|
||||||
|
expect(result).toHaveProperty('created_at');
|
||||||
|
expect(result).toHaveProperty('place');
|
||||||
|
expect(result).toHaveProperty('participants');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nests place fields correctly from flat row', () => {
|
||||||
|
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||||
|
const { place } = result;
|
||||||
|
expect(place.id).toBe(100);
|
||||||
|
expect(place.name).toBe('Eiffel Tower');
|
||||||
|
expect(place.description).toBe('Famous landmark');
|
||||||
|
expect(place.lat).toBe(48.8584);
|
||||||
|
expect(place.lng).toBe(2.2945);
|
||||||
|
expect(place.address).toBe('Champ de Mars, Paris');
|
||||||
|
expect(place.price).toBe(25.0);
|
||||||
|
expect(place.currency).toBe('EUR');
|
||||||
|
expect(place.place_time).toBe('10:00');
|
||||||
|
expect(place.end_time).toBe('12:00');
|
||||||
|
expect(place.duration_minutes).toBe(120);
|
||||||
|
expect(place.notes).toBe('Bring tickets');
|
||||||
|
expect(place.image_url).toBe('https://example.com/img.jpg');
|
||||||
|
expect(place.transport_mode).toBe('walk');
|
||||||
|
expect(place.google_place_id).toBe('ChIJLU7jZClu5kcR4PcOOO6p3I0');
|
||||||
|
expect(place.website).toBe('https://eiffel-tower.com');
|
||||||
|
expect(place.phone).toBe('+33 1 2345 6789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('constructs place.category object when category_id is present', () => {
|
||||||
|
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||||
|
expect(result.place.category).toEqual({
|
||||||
|
id: 5,
|
||||||
|
name: 'Sightseeing',
|
||||||
|
color: '#3b82f6',
|
||||||
|
icon: 'landmark',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets place.category to null when category_id is null', () => {
|
||||||
|
const result = formatAssignmentWithPlace(makeRow({ category_id: null as any }), [], []);
|
||||||
|
expect(result.place.category).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets place.category to null when category_id is 0 (falsy)', () => {
|
||||||
|
const result = formatAssignmentWithPlace(makeRow({ category_id: 0 as any }), [], []);
|
||||||
|
expect(result.place.category).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes provided tags in place.tags', () => {
|
||||||
|
const result = formatAssignmentWithPlace(makeRow(), sampleTags, []);
|
||||||
|
expect(result.place.tags).toEqual(sampleTags);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults place.tags to [] when empty array provided', () => {
|
||||||
|
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||||
|
expect(result.place.tags).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes provided participants', () => {
|
||||||
|
const result = formatAssignmentWithPlace(makeRow(), [], sampleParticipants);
|
||||||
|
expect(result.participants).toEqual(sampleParticipants);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults participants to [] when empty array provided', () => {
|
||||||
|
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||||
|
expect(result.participants).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
105
server/tests/unit/services/weatherService.test.ts
Normal file
105
server/tests/unit/services/weatherService.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||||
|
|
||||||
|
// Prevent the module-level setInterval from running during tests
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
// Mock node-fetch to prevent real HTTP requests
|
||||||
|
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
||||||
|
|
||||||
|
import { estimateCondition, cacheKey } from '../../../src/services/weatherService';
|
||||||
|
|
||||||
|
// ── estimateCondition ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('estimateCondition', () => {
|
||||||
|
describe('heavy precipitation (precipMm > 5)', () => {
|
||||||
|
it('returns Snow when temp <= 0', () => {
|
||||||
|
expect(estimateCondition(0, 6)).toBe('Snow');
|
||||||
|
expect(estimateCondition(-5, 10)).toBe('Snow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Rain when temp > 0', () => {
|
||||||
|
expect(estimateCondition(1, 6)).toBe('Rain');
|
||||||
|
expect(estimateCondition(20, 50)).toBe('Rain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boundary: precipMm = 5.01 and temp = 0 -> Snow', () => {
|
||||||
|
expect(estimateCondition(0, 5.01)).toBe('Snow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boundary: precipMm = 5 is NOT heavy (exactly 5, not > 5) -> falls through', () => {
|
||||||
|
// precipMm = 5 fails the > 5 check, falls to > 1 check -> Snow or Drizzle
|
||||||
|
expect(estimateCondition(0, 5)).toBe('Snow'); // > 1 and temp <= 0
|
||||||
|
expect(estimateCondition(5, 5)).toBe('Drizzle'); // > 1 and temp > 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moderate precipitation (precipMm > 1)', () => {
|
||||||
|
it('returns Snow when temp <= 0', () => {
|
||||||
|
expect(estimateCondition(0, 2)).toBe('Snow');
|
||||||
|
expect(estimateCondition(-10, 1.5)).toBe('Snow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Drizzle when temp > 0', () => {
|
||||||
|
expect(estimateCondition(5, 2)).toBe('Drizzle');
|
||||||
|
expect(estimateCondition(15, 3)).toBe('Drizzle');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('light precipitation (precipMm > 0.3)', () => {
|
||||||
|
it('returns Clouds regardless of temperature', () => {
|
||||||
|
expect(estimateCondition(-5, 0.5)).toBe('Clouds');
|
||||||
|
expect(estimateCondition(25, 0.5)).toBe('Clouds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boundary: precipMm = 0.31 -> Clouds', () => {
|
||||||
|
expect(estimateCondition(20, 0.31)).toBe('Clouds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boundary: precipMm = 0.3 is NOT light precipitation -> falls through', () => {
|
||||||
|
// precipMm = 0.3 fails the > 0.3 check, falls to temperature check
|
||||||
|
expect(estimateCondition(20, 0.3)).toBe('Clear'); // temp > 15
|
||||||
|
expect(estimateCondition(10, 0.3)).toBe('Clouds'); // temp <= 15
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dry conditions (precipMm <= 0.3)', () => {
|
||||||
|
it('returns Clear when temp > 15', () => {
|
||||||
|
expect(estimateCondition(16, 0)).toBe('Clear');
|
||||||
|
expect(estimateCondition(30, 0.1)).toBe('Clear');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Clouds when temp <= 15', () => {
|
||||||
|
expect(estimateCondition(15, 0)).toBe('Clouds');
|
||||||
|
expect(estimateCondition(10, 0)).toBe('Clouds');
|
||||||
|
expect(estimateCondition(-5, 0)).toBe('Clouds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boundary: temp = 15 -> Clouds (not > 15)', () => {
|
||||||
|
expect(estimateCondition(15, 0)).toBe('Clouds');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── cacheKey ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('cacheKey', () => {
|
||||||
|
it('rounds lat and lng to 2 decimal places', () => {
|
||||||
|
expect(cacheKey('48.8566', '2.3522', '2024-06-15')).toBe('48.86_2.35_2024-06-15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses "current" when date is undefined', () => {
|
||||||
|
expect(cacheKey('10.0', '20.0')).toBe('10.00_20.00_current');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles negative coordinates', () => {
|
||||||
|
expect(cacheKey('-33.8688', '151.2093', '2024-01-01')).toBe('-33.87_151.21_2024-01-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pads to 2 decimal places for round numbers', () => {
|
||||||
|
expect(cacheKey('48', '2', '2024-01-01')).toBe('48.00_2.00_2024-01-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the date string as-is', () => {
|
||||||
|
expect(cacheKey('0', '0', 'climate')).toBe('0.00_0.00_climate');
|
||||||
|
});
|
||||||
|
});
|
||||||
145
server/tests/unit/utils/ssrfGuard.test.ts
Normal file
145
server/tests/unit/utils/ssrfGuard.test.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock dns/promises to avoid real DNS lookups in unit tests
|
||||||
|
vi.mock('dns/promises', () => ({
|
||||||
|
default: { lookup: vi.fn() },
|
||||||
|
lookup: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import dns from 'dns/promises';
|
||||||
|
import { checkSsrf } from '../../../src/utils/ssrfGuard';
|
||||||
|
|
||||||
|
const mockLookup = vi.mocked(dns.lookup);
|
||||||
|
|
||||||
|
function mockIp(ip: string) {
|
||||||
|
mockLookup.mockResolvedValue({ address: ip, family: ip.includes(':') ? 6 : 4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('checkSsrf', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
// SEC-001 — Loopback always blocked
|
||||||
|
describe('loopback addresses (always blocked)', () => {
|
||||||
|
it('SEC-001: blocks 127.0.0.1', async () => {
|
||||||
|
mockIp('127.0.0.1');
|
||||||
|
const result = await checkSsrf('http://example.com');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.isPrivate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-001: blocks ::1 (IPv6 loopback)', async () => {
|
||||||
|
mockIp('::1');
|
||||||
|
const result = await checkSsrf('http://example.com');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-001: blocks 127.x.x.x range', async () => {
|
||||||
|
mockIp('127.0.0.2');
|
||||||
|
const result = await checkSsrf('http://example.com');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// SEC-002 — Link-local (AWS metadata) always blocked
|
||||||
|
describe('link-local addresses (always blocked)', () => {
|
||||||
|
it('SEC-002: blocks 169.254.169.254 (AWS metadata)', async () => {
|
||||||
|
mockIp('169.254.169.254');
|
||||||
|
const result = await checkSsrf('http://example.com');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.isPrivate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-002: blocks any 169.254.x.x address', async () => {
|
||||||
|
mockIp('169.254.0.1');
|
||||||
|
const result = await checkSsrf('http://example.com');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// SEC-003 — Private network blocked when ALLOW_INTERNAL_NETWORK is false
|
||||||
|
describe('private network addresses (conditionally blocked)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('ALLOW_INTERNAL_NETWORK', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-003: blocks 10.x.x.x (RFC-1918)', async () => {
|
||||||
|
mockIp('10.0.0.1');
|
||||||
|
const result = await checkSsrf('http://example.com');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.isPrivate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-003: blocks 192.168.x.x (RFC-1918)', async () => {
|
||||||
|
mockIp('192.168.1.100');
|
||||||
|
const result = await checkSsrf('http://example.com');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SEC-003: blocks 172.16.x.x through 172.31.x.x (RFC-1918)', async () => {
|
||||||
|
mockIp('172.16.0.1');
|
||||||
|
const result = await checkSsrf('http://example.com');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// SEC-004 — Private network allowed with ALLOW_INTERNAL_NETWORK=true
|
||||||
|
describe('ALLOW_INTERNAL_NETWORK=true', () => {
|
||||||
|
it('SEC-004: allows private IP when flag is set', async () => {
|
||||||
|
vi.stubEnv('ALLOW_INTERNAL_NETWORK', 'true');
|
||||||
|
mockIp('192.168.1.100');
|
||||||
|
// Need to reload module since ALLOW_INTERNAL_NETWORK is read at module load time
|
||||||
|
vi.resetModules();
|
||||||
|
const { checkSsrf: checkSsrfFresh } = await import('../../../src/utils/ssrfGuard');
|
||||||
|
const { lookup: freshLookup } = await import('dns/promises');
|
||||||
|
vi.mocked(freshLookup).mockResolvedValue({ address: '192.168.1.100', family: 4 });
|
||||||
|
const result = await checkSsrfFresh('http://example.com');
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.isPrivate).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('protocol restrictions', () => {
|
||||||
|
it('rejects non-HTTP/HTTPS protocols', async () => {
|
||||||
|
const result = await checkSsrf('ftp://example.com');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.error).toContain('HTTP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects file:// protocol', async () => {
|
||||||
|
const result = await checkSsrf('file:///etc/passwd');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalid URLs', () => {
|
||||||
|
it('rejects malformed URLs', async () => {
|
||||||
|
const result = await checkSsrf('not-a-url');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.error).toContain('Invalid URL');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('public URLs', () => {
|
||||||
|
it('allows a normal public IP', async () => {
|
||||||
|
mockIp('8.8.8.8');
|
||||||
|
const result = await checkSsrf('https://example.com');
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.isPrivate).toBe(false);
|
||||||
|
expect(result.resolvedIp).toBe('8.8.8.8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('internal hostname suffixes', () => {
|
||||||
|
it('blocks .local domains', async () => {
|
||||||
|
const result = await checkSsrf('http://myserver.local');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks .internal domains', async () => {
|
||||||
|
const result = await checkSsrf('http://service.internal');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
282
server/tests/websocket/connection.test.ts
Normal file
282
server/tests/websocket/connection.test.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket connection tests.
|
||||||
|
* Covers WS-001 to WS-006, WS-008 to WS-010.
|
||||||
|
*
|
||||||
|
* Starts a real HTTP server on a random port and connects via the `ws` library.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||||
|
import http from 'http';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: (placeId: number) => {
|
||||||
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||||
|
if (!place) return null;
|
||||||
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||||
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||||
|
},
|
||||||
|
canAccessTrip: (tripId: any, userId: number) =>
|
||||||
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||||
|
isOwner: (tripId: any, userId: number) =>
|
||||||
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { createTables } from '../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
|
import { createUser, createTrip } from '../helpers/factories';
|
||||||
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
import { setupWebSocket } from '../../src/websocket';
|
||||||
|
import { createEphemeralToken } from '../../src/services/ephemeralTokens';
|
||||||
|
|
||||||
|
let server: http.Server;
|
||||||
|
let wsUrl: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
server = http.createServer(app);
|
||||||
|
setupWebSocket(server);
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => server.listen(0, resolve));
|
||||||
|
const addr = server.address() as { port: number };
|
||||||
|
wsUrl = `ws://127.0.0.1:${addr.port}/ws`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
server.close(err => err ? reject(err) : resolve())
|
||||||
|
);
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
loginAttempts.clear();
|
||||||
|
mfaAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Buffered WebSocket wrapper that never drops messages. */
|
||||||
|
class WsClient {
|
||||||
|
private ws: WebSocket;
|
||||||
|
private buffer: any[] = [];
|
||||||
|
private waiters: Array<(msg: any) => void> = [];
|
||||||
|
|
||||||
|
constructor(ws: WebSocket) {
|
||||||
|
this.ws = ws;
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
const waiter = this.waiters.shift();
|
||||||
|
if (waiter) {
|
||||||
|
waiter(msg);
|
||||||
|
} else {
|
||||||
|
this.buffer.push(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next(timeoutMs = 3000): Promise<any> {
|
||||||
|
if (this.buffer.length > 0) return Promise.resolve(this.buffer.shift());
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const idx = this.waiters.indexOf(resolve);
|
||||||
|
if (idx !== -1) this.waiters.splice(idx, 1);
|
||||||
|
reject(new Error('Message timeout'));
|
||||||
|
}, timeoutMs);
|
||||||
|
this.waiters.push((msg) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(msg);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
send(msg: object) { this.ws.send(JSON.stringify(msg)); }
|
||||||
|
close() { this.ws.close(); }
|
||||||
|
|
||||||
|
/** Wait for any message matching predicate within timeout. */
|
||||||
|
waitFor(predicate: (m: any) => boolean, timeoutMs = 3000): Promise<any> {
|
||||||
|
// Check buffer first
|
||||||
|
const idx = this.buffer.findIndex(predicate);
|
||||||
|
if (idx !== -1) return Promise.resolve(this.buffer.splice(idx, 1)[0]);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('waitFor timeout')), timeoutMs);
|
||||||
|
const handler = (msg: any) => {
|
||||||
|
if (predicate(msg)) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(msg);
|
||||||
|
} else {
|
||||||
|
this.buffer.push(msg);
|
||||||
|
// re-register
|
||||||
|
this.waiters.push(handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.waiters.push(handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect messages for a given duration. */
|
||||||
|
collectFor(ms: number): Promise<any[]> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const msgs: any[] = [...this.buffer.splice(0)];
|
||||||
|
const handleMsg = (msg: any) => msgs.push(msg);
|
||||||
|
this.ws.on('message', (data) => handleMsg(JSON.parse(data.toString())));
|
||||||
|
setTimeout(() => resolve(msgs), ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWs(token?: string): Promise<WsClient> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = token ? `${wsUrl}?token=${encodeURIComponent(token)}` : wsUrl;
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
const client = new WsClient(ws);
|
||||||
|
ws.once('open', () => resolve(client));
|
||||||
|
ws.once('error', reject);
|
||||||
|
ws.once('close', (code) => {
|
||||||
|
if (code === 4001) reject(new Error(`WS closed with 4001`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WS connection', () => {
|
||||||
|
it('WS-001 — connects with valid ephemeral token and receives welcome', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const token = createEphemeralToken(user.id, 'ws')!;
|
||||||
|
|
||||||
|
const client = await connectWs(token);
|
||||||
|
try {
|
||||||
|
const msg = await client.next();
|
||||||
|
expect(msg.type).toBe('welcome');
|
||||||
|
expect(typeof msg.socketId).toBe('number');
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WS-002 — connecting without token closes with code 4001', async () => {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
ws.on('close', (code) => {
|
||||||
|
expect(code).toBe(4001);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
ws.on('error', () => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WS-003 — connecting with invalid token closes with code 4001', async () => {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const ws = new WebSocket(`${wsUrl}?token=invalid-token-xyz`);
|
||||||
|
ws.on('close', (code) => {
|
||||||
|
expect(code).toBe(4001);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
ws.on('error', () => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WS rooms', () => {
|
||||||
|
it('WS-004 — join trip room receives joined confirmation', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const token = createEphemeralToken(user.id, 'ws')!;
|
||||||
|
|
||||||
|
const client = await connectWs(token);
|
||||||
|
try {
|
||||||
|
await client.next(); // welcome
|
||||||
|
|
||||||
|
client.send({ type: 'join', tripId: trip.id });
|
||||||
|
const msg = await client.next();
|
||||||
|
expect(msg.type).toBe('joined');
|
||||||
|
expect(msg.tripId).toBe(trip.id);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WS-005 — join trip without access receives error', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { user: otherUser } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, otherUser.id); // trip owned by otherUser
|
||||||
|
const token = createEphemeralToken(user.id, 'ws')!;
|
||||||
|
|
||||||
|
const client = await connectWs(token);
|
||||||
|
try {
|
||||||
|
await client.next(); // welcome
|
||||||
|
|
||||||
|
client.send({ type: 'join', tripId: trip.id });
|
||||||
|
const msg = await client.next();
|
||||||
|
expect(msg.type).toBe('error');
|
||||||
|
expect(msg.message).toMatch(/access denied/i);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WS-006 — leave room receives left confirmation', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const token = createEphemeralToken(user.id, 'ws')!;
|
||||||
|
|
||||||
|
const client = await connectWs(token);
|
||||||
|
try {
|
||||||
|
await client.next(); // welcome
|
||||||
|
|
||||||
|
client.send({ type: 'join', tripId: trip.id });
|
||||||
|
await client.next(); // joined
|
||||||
|
|
||||||
|
client.send({ type: 'leave', tripId: trip.id });
|
||||||
|
const msg = await client.next();
|
||||||
|
expect(msg.type).toBe('left');
|
||||||
|
expect(msg.tripId).toBe(trip.id);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WS rate limiting', () => {
|
||||||
|
it('WS-008 — exceeding 30 messages per window triggers rate-limit error', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const token = createEphemeralToken(user.id, 'ws')!;
|
||||||
|
|
||||||
|
const client = await connectWs(token);
|
||||||
|
try {
|
||||||
|
await client.next(); // welcome
|
||||||
|
|
||||||
|
// Send 35 messages quickly — at least one should trigger rate limit
|
||||||
|
for (let i = 0; i < 35; i++) {
|
||||||
|
client.send({ type: 'ping' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect for up to 2s and find a rate-limit error
|
||||||
|
const msgs = await client.collectFor(1500);
|
||||||
|
const rateLimitMsg = msgs.find((m: any) => m.type === 'error' && m.message?.includes('Rate limit'));
|
||||||
|
expect(rateLimitMsg).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
33
server/vitest.config.ts
Normal file
33
server/vitest.config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
root: '.',
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['tests/setup.ts'],
|
||||||
|
testTimeout: 15000,
|
||||||
|
hookTimeout: 15000,
|
||||||
|
pool: 'forks',
|
||||||
|
silent: false,
|
||||||
|
reporters: ['verbose'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['lcov', 'text'],
|
||||||
|
reportsDirectory: './coverage',
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@modelcontextprotocol/sdk/server/mcp': new URL(
|
||||||
|
'./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js',
|
||||||
|
import.meta.url
|
||||||
|
).pathname,
|
||||||
|
'@modelcontextprotocol/sdk/server/streamableHttp': new URL(
|
||||||
|
'./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp.js',
|
||||||
|
import.meta.url
|
||||||
|
).pathname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
14
sonar-project.properties
Normal file
14
sonar-project.properties
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
sonar.projectKey=TREK
|
||||||
|
sonar.projectName=TREK
|
||||||
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
|
# Sources
|
||||||
|
sonar.sources=client/src,server/src
|
||||||
|
sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
sonar.tests=server/tests
|
||||||
|
sonar.test.inclusions=server/tests/**/*.ts
|
||||||
|
|
||||||
|
# Coverage — path relative to repo root
|
||||||
|
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info
|
||||||
Reference in New Issue
Block a user