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:
Julien G.
2026-04-03 13:17:53 +02:00
committed by GitHub
parent d48714d17a
commit 905c7d460b
74 changed files with 12821 additions and 311 deletions

View 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)}` };
}

View 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;
}

View 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: () => {},
};

View 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); });
});
}
}