diff --git a/server/src/index.ts b/server/src/index.ts index 6668eeea0..e48fbaeb6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -32,7 +32,7 @@ const HOST = readEnv().app.host; const APP_VERSION: string = readEnv().app.appVersion || (require('../package.json') as { version: string }).version; const onListen = () => { - const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./services/auditLog'); + const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./nest/audit/audit-log.logger'); const env = readEnv(); const LOG_LVL = (env.app.logLevel || 'info').toLowerCase(); const tz = env.app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; @@ -116,7 +116,7 @@ bootstrap().catch((err) => { // Graceful shutdown function shutdown(signal: string): void { - const { logInfo: sLogInfo, logError: sLogError } = require('./services/auditLog'); + const { logInfo: sLogInfo, logError: sLogError } = require('./nest/audit/audit-log.logger'); const { closeMcpSessions } = require('./mcp'); sLogInfo(`${signal} received — shutting down gracefully...`); scheduler.stop(); diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index ce340b297..ebbd78b32 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -11,7 +11,7 @@ import { registerResources } from './resources'; import { registerTools } from './tools'; import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient, evictOldestSessionForUser } from './sessionManager'; import { readEnv } from '../app-config'; -import { writeAudit, getClientIp } from '../services/auditLog'; +import { writeAudit, getClientIp } from '../nest/audit/audit.bridge'; import { getMcpSafeUrl } from '../services/notifications'; export { revokeUserSessions, revokeUserSessionsForClient }; diff --git a/server/src/mcp/oauthProvider.ts b/server/src/mcp/oauthProvider.ts index 592fd2820..87e9bdffa 100644 --- a/server/src/mcp/oauthProvider.ts +++ b/server/src/mcp/oauthProvider.ts @@ -17,7 +17,7 @@ import { } from '../services/oauthService'; import { ALL_SCOPES } from './scopes'; import { getMcpSafeUrl } from '../services/notifications'; -import { writeAudit } from '../services/auditLog'; +import { writeAudit } from '../nest/audit/audit.bridge'; // --------------------------------------------------------------------------- // DB row type (mirrors oauthService.ts) diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index 13f6305a9..ff2abe3b1 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -4,7 +4,7 @@ import cors from 'cors'; import helmet from 'helmet'; import cookieParser from 'cookie-parser'; import { readEnv, type AppEnv } from '../app-config'; -import { logDebug, logWarn, logError } from '../services/auditLog'; +import { logDebug, logWarn, logError } from '../nest/audit/audit-log.logger'; import { enforceGlobalMfaPolicy } from './mfaPolicy'; /** diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index f962fe393..c2baddffc 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -6,7 +6,9 @@ import { PluginRuntimeService } from '../plugins/plugin-runtime.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AdminGuard } from '../auth/admin.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { writeAudit, getClientIp, logInfo } from '../../services/auditLog'; +import { getClientIp } from '../audit/client-ip'; +import { logInfo } from '../audit/audit-log.logger'; +import { AuditService } from '../audit/audit.service'; import { send as sendNotification } from '../../services/notificationService'; import type { User } from '../../types'; @@ -35,6 +37,7 @@ export class AdminController { constructor( private readonly admin: AdminService, private readonly pluginRuntime: PluginRuntimeService, + private readonly audit: AuditService, ) {} // ── Users ── @@ -45,14 +48,14 @@ export class AdminController { @HttpCode(201) createUser(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) { const result = ok(this.admin.createUser(body)); - writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails }); + this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails }); return { user: result.user }; } @Put('users/:id') updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) { const result = ok(this.admin.updateUser(id, body)); - writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } }); + this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } }); logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`); return { user: result.user }; } @@ -60,7 +63,7 @@ export class AdminController { @Delete('users/:id') deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { const result = ok(this.admin.deleteUser(id, user.id)); - writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } }); + this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } }); logInfo(`Admin ${user.email} deleted user ${result.email}`); return { success: true }; } @@ -68,7 +71,7 @@ export class AdminController { @Delete('users/:id/passkeys') resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { const result = ok(this.admin.resetUserPasskeys(id)); - writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } }); + this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } }); return { success: true, deleted: result.deleted }; } @@ -85,7 +88,7 @@ export class AdminController { throw new HttpException({ error: 'permissions object required' }, 400); } const result = this.admin.savePermissions(body.permissions as unknown as Parameters[0]); - writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record }); + this.audit.writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record }); return { success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) }; } @@ -102,7 +105,7 @@ export class AdminController { if (result.error) { throw new HttpException({ error: result.error }, result.status || 400); } - writeAudit({ userId: user.id, action: 'admin.oidc_update', ip: getClientIp(req), details: { issuer_set: !!body.issuer } }); + this.audit.writeAudit({ userId: user.id, action: 'admin.oidc_update', ip: getClientIp(req), details: { issuer_set: !!body.issuer } }); return { success: true }; } @@ -113,7 +116,7 @@ export class AdminController { if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) }); return { success: true, message: result.message }; } @@ -148,14 +151,14 @@ export class AdminController { @HttpCode(201) createInvite(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) { const result = this.admin.createInvite(user.id, body); - writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } }); + this.audit.writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } }); return { invite: result.invite }; } @Delete('invites/:id') deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { ok(this.admin.deleteInvite(id)); - writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) }); return { success: true }; } @@ -166,7 +169,7 @@ export class AdminController { @Put('bag-tracking') updateBagTracking(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) { const result = this.admin.updateBagTracking(body.enabled); - writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } }); + this.audit.writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } }); return result; } @@ -177,7 +180,7 @@ export class AdminController { updatePlacesPhotos(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) { if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400); const result = this.admin.updatePlacesPhotos(body.enabled); - writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } }); + this.audit.writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } }); return result; } @@ -188,7 +191,7 @@ export class AdminController { updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) { if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400); const result = this.admin.updatePlacesAutocomplete(body.enabled); - writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } }); + this.audit.writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } }); return result; } @@ -199,7 +202,7 @@ export class AdminController { updatePlacesDetails(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) { if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400); const result = this.admin.updatePlacesDetails(body.enabled); - writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } }); + this.audit.writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } }); return result; } @@ -212,7 +215,7 @@ export class AdminController { // Collab flags gate MCP registration, but a no-op save must not tear down // every live MCP session (#1414). if (changed) this.admin.invalidateMcpSessions(); - writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features }); + this.audit.writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features }); return features; } @@ -235,7 +238,7 @@ export class AdminController { @Delete('packing-templates/:id') deletePackingTemplate(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { const result = ok(this.admin.deletePackingTemplate(id)); - writeAudit({ userId: user.id, action: 'admin.packing_template_delete', resource: String(id), ip: getClientIp(req), details: { name: result.name } }); + this.audit.writeAudit({ userId: user.id, action: 'admin.packing_template_delete', resource: String(id), ip: getClientIp(req), details: { name: result.name } }); return { success: true }; } @@ -278,7 +281,7 @@ export class AdminController { @Put('addons/:id') async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) { const result = ok(this.admin.updateAddon(id, body)); - writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails }); + this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails }); // Sessions only need re-creating when the registered MCP surface can // actually change — an enabled-flip of an MCP-relevant addon. Config-only // saves and photo-provider toggles used to kill every session (#1414). @@ -308,7 +311,7 @@ export class AdminController { @Delete('oauth-sessions/:id') revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { ok(this.admin.revokeOAuthSession(id)); - writeAudit({ userId: user.id, action: 'admin.oauth_session.revoke', resource: String(id), ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session.revoke', resource: String(id), ip: getClientIp(req) }); return { success: true }; } @@ -320,7 +323,7 @@ export class AdminController { if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) }); return { success: true }; } @@ -335,7 +338,7 @@ export class AdminController { } try { this.admin.setAdminUserDefaults(body as unknown as Record); - writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', ip: getClientIp(req), details: body as Record }); + this.audit.writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', ip: getClientIp(req), details: body as Record }); return this.admin.getAdminUserDefaults(); } catch (err) { throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400); diff --git a/server/src/nest/admin/admin.module.ts b/server/src/nest/admin/admin.module.ts index d0ad7c2d6..421ea1963 100644 --- a/server/src/nest/admin/admin.module.ts +++ b/server/src/nest/admin/admin.module.ts @@ -3,9 +3,10 @@ import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; import { PluginsModule } from '../plugins/plugins.module'; import { SettingsModule } from '../settings/settings.module'; +import { AuditModule } from '../audit/audit.module'; @Module({ - imports: [PluginsModule, SettingsModule], + imports: [PluginsModule, SettingsModule, AuditModule], controllers: [AdminController], providers: [AdminService], }) diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 8b4a8c9d9..33bc34fcb 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -43,6 +43,7 @@ import { OidcModule } from './oidc/oidc.module'; import { OauthModule } from './oauth/oauth.module'; import { AdminModule } from './admin/admin.module'; import { AddonsModule } from './addons/addons.module'; +import { AuditModule } from './audit/audit.module'; import { PermissionsModule } from './permissions/permissions.module'; import { PluginsModule } from './plugins/plugins.module'; import { McpModule } from '@trek/nest-mcp'; @@ -57,7 +58,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy }), WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, PermissionsModule, PluginsModule, BookingImportModule], + imports: [AppConfigModule, DatabaseModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy }), WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule], controllers: [HealthController], providers: [ HealthService, diff --git a/server/src/nest/audit/audit-log.logger.ts b/server/src/nest/audit/audit-log.logger.ts new file mode 100644 index 000000000..d469ee604 --- /dev/null +++ b/server/src/nest/audit/audit-log.logger.ts @@ -0,0 +1,85 @@ +import { readEnv } from '../../app-config'; +import fs from 'fs'; +import path from 'path'; + +/** + * The server's rotating file logger — a plain, deliberately side-effectful + * module, NOT an injectable. This is a documented parity exception to the + * "importable without side effects" rule: index.ts lazy-requires it before any + * Nest container exists (boot ordering), and tests/setup.ts relies on env + * being read exactly once at first import (the LOG_LEVEL freeze below). + */ + +// Frozen at import on purpose (legacy timing; tests/setup.ts sets it pre-import). +const LOG_LEVEL = (readEnv().app.logLevel || 'info').toLowerCase(); +const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB +const MAX_LOG_FILES = 5; + +const C = { + blue: '\x1b[34m', + cyan: '\x1b[36m', + red: '\x1b[31m', + yellow: '\x1b[33m', + reset: '\x1b[0m', +}; + +// ── File logger with rotation ───────────────────────────────────────────── + +const logsDir = path.join(process.cwd(), 'data/logs'); +try { fs.mkdirSync(logsDir, { recursive: true }); } catch {} +const logFilePath = path.join(logsDir, 'trek.log'); + +function rotateIfNeeded(): void { + try { + if (!fs.existsSync(logFilePath)) return; + const stat = fs.statSync(logFilePath); + if (stat.size < MAX_LOG_SIZE) return; + + for (let i = MAX_LOG_FILES - 1; i >= 1; i--) { + const src = i === 1 ? logFilePath : `${logFilePath}.${i - 1}`; + const dst = `${logFilePath}.${i}`; + if (fs.existsSync(src)) fs.renameSync(src, dst); + } + } catch {} +} + +function writeToFile(line: string): void { + try { + rotateIfNeeded(); + fs.appendFileSync(logFilePath, line + '\n'); + } catch {} +} + +// ── Public log helpers ──────────────────────────────────────────────────── + +function formatTs(): string { + const tz = readEnv().app.tz || 'UTC'; + return new Date().toLocaleString('sv-SE', { timeZone: tz }).replace(' ', 'T'); +} + +export function logInfo(msg: string): void { + const ts = formatTs(); + console.log(`${C.blue}[INFO]${C.reset} ${ts} ${msg}`); + writeToFile(`[INFO] ${ts} ${msg}`); +} + +export function logDebug(msg: string): void { + if (LOG_LEVEL !== 'debug') return; + const ts = formatTs(); + console.log(`${C.cyan}[DEBUG]${C.reset} ${ts} ${msg}`); + writeToFile(`[DEBUG] ${ts} ${msg}`); +} + +export function logError(msg: string): void { + const ts = formatTs(); + console.error(`${C.red}[ERROR]${C.reset} ${ts} ${msg}`); + writeToFile(`[ERROR] ${ts} ${msg}`); +} + +export function logWarn(msg: string): void { + const ts = formatTs(); + console.warn(`${C.yellow}[WARN]${C.reset} ${ts} ${msg}`); + writeToFile(`[WARN] ${ts} ${msg}`); +} + +export { LOG_LEVEL }; diff --git a/server/src/nest/audit/audit.bridge.ts b/server/src/nest/audit/audit.bridge.ts new file mode 100644 index 000000000..9f32835cb --- /dev/null +++ b/server/src/nest/audit/audit.bridge.ts @@ -0,0 +1,26 @@ +import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; +import { AuditService } from './audit.service'; + +/** + * Non-Nest entry point for the audit domain — for code running OUTSIDE the + * Nest container (src/index.ts lazy-requires, src/mcp/index.ts and + * oauthProvider.ts, and the legacy airtrail/immich/oauth services). Exports + * the full legacy services/auditLog surface 1:1 so repointing a consumer is an + * import-path-only diff (log*-only consumers import audit-log.logger + * directly). Inside the container, inject AuditService instead. Delete this + * file when the last legacy consumer migrates. + * + * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto + * the shared better-sqlite3 singleton. + */ +const audit = new AuditService(new DatabaseService(db)); + +export function writeAudit(entry: Parameters[0]): void { + return audit.writeAudit(entry); +} + +export { getClientIp } from './client-ip'; +// LOG_LEVEL has no external consumers today — re-exported purely for 1:1 +// surface parity with the legacy module. +export { LOG_LEVEL, logInfo, logDebug, logError, logWarn } from './audit-log.logger'; diff --git a/server/src/nest/audit/audit.module.ts b/server/src/nest/audit/audit.module.ts new file mode 100644 index 000000000..c401144e9 --- /dev/null +++ b/server/src/nest/audit/audit.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuditService } from './audit.service'; + +/** Cross-cutting audit domain (Wave 2). No controller/MCP surface of its own — + * the audit-log READ side stays with AdminModule (legacy adminService). + * Exports AuditService (writeAudit) for the in-container consumers; the pure + * helpers (getClientIp, log*) are plain imports from client-ip.ts / + * audit-log.logger.ts. Not @Global — e2e TestingModules resolve it + * transitively through each consumer's explicit import. Registered in + * AppModule. */ +@Module({ + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/server/src/nest/audit/audit.service.ts b/server/src/nest/audit/audit.service.ts new file mode 100644 index 000000000..3f447d428 --- /dev/null +++ b/server/src/nest/audit/audit.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { logInfo, logDebug, logError } from './audit-log.logger'; + +const ACTION_LABELS: Record = { + 'user.register': 'registered', + 'user.login': 'logged in', + 'user.login_failed': 'login failed', + 'user.password_change': 'changed password', + 'user.account_delete': 'deleted account', + 'user.mfa_enable': 'enabled MFA', + 'user.mfa_disable': 'disabled MFA', + 'settings.app_update': 'updated settings', + 'trip.create': 'created trip', + 'trip.delete': 'deleted trip', + 'admin.user_role_change': 'changed user role', + 'admin.user_delete': 'deleted user', + 'admin.plugin_retrust': "re-trusted a plugin's author signing key", + 'admin.invite_create': 'created invite', + 'immich.private_ip_configured': 'configured Immich with private IP', +}; + +function buildInfoSummary(action: string, details?: Record): string { + if (!details || Object.keys(details).length === 0) return ''; + if (action === 'trip.create') return ` "${details.title}"`; + if (action === 'trip.delete') return ` tripId=${details.tripId}`; + if (action === 'user.register') return ` ${details.email}`; + if (action === 'user.login') return ''; + if (action === 'user.login_failed') return ` reason=${details.reason}`; + if (action === 'settings.app_update') { + const parts: string[] = []; + if (details.notification_channel) parts.push(`channel=${details.notification_channel}`); + if (details.smtp_settings_updated) parts.push('smtp'); + if (details.notification_events_updated) parts.push('events'); + if (details.webhook_url_updated) parts.push('webhook_url'); + if (details.allowed_file_types_updated) parts.push('file_types'); + if (details.allow_registration !== undefined) parts.push(`registration=${details.allow_registration}`); + if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`); + return parts.length ? ` (${parts.join(', ')})` : ''; + } + if (action === 'immich.private_ip_configured') { + return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : ''; + } + return ''; +} + +@Injectable() +export class AuditService { + constructor(private readonly dbs: DatabaseService) {} + + private resolveUserEmail(userId: number | null): string { + if (!userId) return 'anonymous'; + try { + const row = this.dbs.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId); + return row?.email || `uid:${userId}`; + } catch { return `uid:${userId}`; } + } + + /** Best-effort; never throws — failures are logged only. */ + writeAudit(entry: { + userId: number | null; + action: string; + resource?: string | null; + details?: Record; + debugDetails?: Record; + ip?: string | null; + }): void { + try { + const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null; + this.dbs.run( + `INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`, + entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null + ); + + const email = this.resolveUserEmail(entry.userId); + const label = ACTION_LABELS[entry.action] || entry.action; + const brief = buildInfoSummary(entry.action, entry.details); + logInfo(`${email} ${label}${brief} ip=${entry.ip || '-'}`); + + if (entry.debugDetails && Object.keys(entry.debugDetails).length > 0) { + logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${JSON.stringify(entry.debugDetails)}`); + } else if (detailsJson) { + logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${detailsJson}`); + } + } catch (e) { + logError(`Audit write failed: ${e instanceof Error ? e.message : e}`); + } + } +} diff --git a/server/src/nest/audit/client-ip.ts b/server/src/nest/audit/client-ip.ts new file mode 100644 index 000000000..8e44a77a5 --- /dev/null +++ b/server/src/nest/audit/client-ip.ts @@ -0,0 +1,16 @@ +import { Request } from 'express'; + +/** + * Pure request helper — no DB, no side effects — kept out of the injectable + * AuditService so getClientIp-only consumers stay plain-function imports + * (same carve-out precedent as files.constants.ts). + */ +export function getClientIp(req: Request): string | null { + const xff = req.headers['x-forwarded-for']; + if (typeof xff === 'string') { + const first = xff.split(',')[0]?.trim(); + return first || null; + } + if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null; + return req.socket?.remoteAddress || null; +} diff --git a/server/src/nest/auth/auth-public.controller.ts b/server/src/nest/auth/auth-public.controller.ts index 5f91214ba..7b68238dd 100644 --- a/server/src/nest/auth/auth-public.controller.ts +++ b/server/src/nest/auth/auth-public.controller.ts @@ -3,7 +3,8 @@ import type { Request, Response } from 'express'; import { AuthService } from './auth.service'; import { RateLimitService } from './rate-limit.service'; import { OptionalJwtGuard } from './optional-jwt.guard'; -import { writeAudit, getClientIp } from '../../services/auditLog'; +import { getClientIp } from '../audit/client-ip'; +import { AuditService } from '../audit/audit.service'; import { willDropSecureCookie } from '../../services/cookie'; import type { User } from '../../types'; @@ -23,7 +24,7 @@ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); */ @Controller('api/auth') export class AuthPublicController { - constructor(private readonly auth: AuthService, private readonly rl: RateLimitService) {} + constructor(private readonly auth: AuthService, private readonly rl: RateLimitService, private readonly audit: AuditService) {} private limit(bucket: string, req: Request, max: number): void { if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) { @@ -66,7 +67,7 @@ export class AuthPublicController { if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails }); + this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails }); this.auth.setAuthCookie(res, result.token!, req); return { token: result.token, user: result.user }; } @@ -78,7 +79,7 @@ export class AuthPublicController { const started = Date.now(); const result = this.auth.loginUser(body); if (result.auditAction) { - writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails }); + this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails }); } const elapsed = Date.now() - started; if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed); @@ -110,15 +111,15 @@ export class AuthPublicController { if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) { const origin = this.auth.getAppUrl(); const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`; - writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } }); + this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } }); try { const delivery = await this.auth.sendPasswordResetEmail(outcome.userEmail, url, outcome.userId); - writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } }); + this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } }); } catch { - writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } }); + this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } }); } } else { - writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } }); + this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } }); } const elapsed = Date.now() - started; if (elapsed < FORGOT_MIN_LATENCY_MS) await delay(FORGOT_MIN_LATENCY_MS - elapsed); @@ -134,13 +135,13 @@ export class AuthPublicController { const ip = getClientIp(req); const result = this.auth.resetPassword(body); if (result.error) { - writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } }); + this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } }); throw new HttpException({ error: result.error }, result.status!); } if (result.mfa_required) { return { mfa_required: true }; } - writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip }); + this.audit.writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip }); return { success: true }; } @@ -152,7 +153,7 @@ export class AuthPublicController { if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } }); + this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } }); this.auth.setAuthCookie(res, result.token!, req, result.remember); return { token: result.token, user: result.user }; } diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index 5c47eace3..edba212e1 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -25,7 +25,8 @@ import { AuthService } from './auth.service'; import { RateLimitService } from './rate-limit.service'; import { JwtAuthGuard } from './jwt-auth.guard'; import { CurrentUser } from './current-user.decorator'; -import { writeAudit, getClientIp } from '../../services/auditLog'; +import { getClientIp } from '../audit/client-ip'; +import { AuditService } from '../audit/audit.service'; import { isDemoEmail } from '../../services/demo'; import type { User } from '../../types'; @@ -60,7 +61,7 @@ const AVATAR_UPLOAD = { @Controller('api/auth') @UseGuards(JwtAuthGuard) export class AuthController { - constructor(private readonly auth: AuthService, private readonly rl: RateLimitService) {} + constructor(private readonly auth: AuthService, private readonly rl: RateLimitService, private readonly audit: AuditService) {} private limit(bucket: string, req: Request, max: number): void { if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) { @@ -87,7 +88,7 @@ export class AuthController { // Refresh this device's cookie with the new password_version so the user // stays logged in here while all other sessions are invalidated. if (result.token) this.auth.setAuthCookie(res, result.token, req); - writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) }); return { success: true }; } @@ -97,7 +98,7 @@ export class AuthController { if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) }); return { success: true }; } @@ -176,7 +177,7 @@ export class AuthController { if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails }); + this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails }); return { success: true }; } @@ -209,7 +210,7 @@ export class AuthController { if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) }); return { success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes }; } @@ -221,7 +222,7 @@ export class AuthController { if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) }); return { success: true, mfa_enabled: result.mfa_enabled }; } diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index 7c649537a..fcf994a09 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -4,6 +4,7 @@ import { AuthController } from './auth.controller'; import { PasskeyController } from './passkey.controller'; import { AuthService } from './auth.service'; import { RateLimitService } from './rate-limit.service'; +import { AuditModule } from '../audit/audit.module'; /** * Auth module — public flows (login/register/reset/mfa-verify/logout) and the @@ -12,6 +13,7 @@ import { RateLimitService } from './rate-limit.service'; * sub-paths explicitly rather than claiming all of /api/auth. */ @Module({ + imports: [AuditModule], controllers: [AuthPublicController, AuthController, PasskeyController], providers: [AuthService, RateLimitService], }) diff --git a/server/src/nest/auth/passkey.controller.ts b/server/src/nest/auth/passkey.controller.ts index e4f70749d..d5746216b 100644 --- a/server/src/nest/auth/passkey.controller.ts +++ b/server/src/nest/auth/passkey.controller.ts @@ -5,7 +5,8 @@ import { JwtAuthGuard } from './jwt-auth.guard'; import { PasskeyEnabledGuard } from './passkey-enabled.guard'; import { CurrentUser } from './current-user.decorator'; import { setAuthCookie } from '../../services/cookie'; -import { writeAudit, getClientIp } from '../../services/auditLog'; +import { getClientIp } from '../audit/client-ip'; +import { AuditService } from '../audit/audit.service'; import * as passkey from '../../services/passkeyService'; import type { User } from '../../types'; @@ -27,7 +28,7 @@ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); */ @Controller('api/auth/passkey') export class PasskeyController { - constructor(private readonly rl: RateLimitService) {} + constructor(private readonly rl: RateLimitService, private readonly audit: AuditService) {} private limit(bucket: string, req: Request, max: number): void { if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) { @@ -52,7 +53,7 @@ export class PasskeyController { async registerVerify(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) { const result = await passkey.passkeyRegisterVerify(user.id, body as Parameters[1]); if (result.error) throw new HttpException({ error: result.error }, result.status!); - writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) }); return { success: true, credential: result.credential }; } @@ -75,14 +76,14 @@ export class PasskeyController { const started = Date.now(); const result = await passkey.passkeyLoginVerify(body as Parameters[0]); if (result.auditAction) { - writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) }); + this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) }); } // Pad to the same floor as password login so timing can't distinguish a // known credential from an unknown one. const elapsed = Date.now() - started; if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed); if (result.error) throw new HttpException({ error: result.error }, result.status!); - writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } }); + this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } }); setAuthCookie(res, result.token!, req); return { token: result.token, user: result.user }; } @@ -108,7 +109,7 @@ export class PasskeyController { this.limit('login', req, 5); const result = passkey.deletePasskey(user.id, id, body?.password); if (result.error) throw new HttpException({ error: result.error }, result.status!); - writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) }); return { success: true }; } } diff --git a/server/src/nest/backup/backup.controller.ts b/server/src/nest/backup/backup.controller.ts index d9a5b8104..0c95ab66d 100644 --- a/server/src/nest/backup/backup.controller.ts +++ b/server/src/nest/backup/backup.controller.ts @@ -23,7 +23,8 @@ import { BackupService } from './backup.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AdminGuard } from '../auth/admin.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { writeAudit, getClientIp } from '../../services/auditLog'; +import { getClientIp } from '../audit/client-ip'; +import { AuditService } from '../audit/audit.service'; import { getUploadTmpDir, MAX_BACKUP_UPLOAD_SIZE } from '../../services/backupService'; const UPLOAD = { @@ -47,7 +48,7 @@ const UPLOAD = { @Controller('api/backup') @UseGuards(JwtAuthGuard, AdminGuard) export class BackupController { - constructor(private readonly backup: BackupService) {} + constructor(private readonly backup: BackupService, private readonly audit: AuditService) {} @Get('list') list() { @@ -66,7 +67,7 @@ export class BackupController { } try { const backup = await this.backup.createBackup(); - writeAudit({ userId: user.id, action: 'backup.create', resource: backup.filename, ip: getClientIp(req), details: { size: backup.size } }); + this.audit.writeAudit({ userId: user.id, action: 'backup.create', resource: backup.filename, ip: getClientIp(req), details: { size: backup.size } }); return { success: true, backup }; } catch { throw new HttpException({ error: 'Error creating backup' }, 500); @@ -98,7 +99,7 @@ export class BackupController { if (!result.success) { throw new HttpException({ error: result.error }, result.status || 400); } - writeAudit({ userId: user.id, action: 'backup.restore', resource: filename, ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'backup.restore', resource: filename, ip: getClientIp(req) }); return { success: true }; } catch (err) { if (err instanceof HttpException) throw err; @@ -120,7 +121,7 @@ export class BackupController { if (!result.success) { throw new HttpException({ error: result.error }, result.status || 400); } - writeAudit({ userId: user.id, action: 'backup.upload_restore', resource: origName, ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'backup.upload_restore', resource: origName, ip: getClientIp(req) }); return { success: true }; } catch (err) { if (err instanceof HttpException) throw err; @@ -144,7 +145,7 @@ export class BackupController { updateAutoSettings(@CurrentUser() user: User, @Body() body: Record, @Req() req: Request) { try { const settings = this.backup.updateAutoSettings(body || {}); - writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } }); + this.audit.writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } }); return { settings }; } catch (err) { console.error('[backup] PUT auto-settings:', err); @@ -162,7 +163,7 @@ export class BackupController { throw new HttpException({ error: 'Backup not found' }, 404); } this.backup.deleteBackup(filename); - writeAudit({ userId: user.id, action: 'backup.delete', resource: filename, ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'backup.delete', resource: filename, ip: getClientIp(req) }); return { success: true }; } } diff --git a/server/src/nest/backup/backup.module.ts b/server/src/nest/backup/backup.module.ts index 4a79055ae..9d1a8765f 100644 --- a/server/src/nest/backup/backup.module.ts +++ b/server/src/nest/backup/backup.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { BackupController } from './backup.controller'; import { BackupService } from './backup.service'; +import { AuditModule } from '../audit/audit.module'; @Module({ + imports: [AuditModule], controllers: [BackupController], providers: [BackupService], }) diff --git a/server/src/nest/integrations/airtrail.controller.ts b/server/src/nest/integrations/airtrail.controller.ts index b2a360ef9..7bcf72e78 100644 --- a/server/src/nest/integrations/airtrail.controller.ts +++ b/server/src/nest/integrations/airtrail.controller.ts @@ -5,7 +5,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { AirtrailAddonGuard } from './airtrail-addon.guard'; import { AirtrailSettingsDto } from './airtrail.dto'; -import { getClientIp } from '../../services/auditLog'; +import { getClientIp } from '../audit/client-ip'; import { getConnectionSettings, getConnectionStatus, diff --git a/server/src/nest/memories/immich.controller.ts b/server/src/nest/memories/immich.controller.ts index d046ce879..769a9a5b0 100644 --- a/server/src/nest/memories/immich.controller.ts +++ b/server/src/nest/memories/immich.controller.ts @@ -4,7 +4,7 @@ import type { User } from '../../types'; import { MemoriesService } from './memories.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { getClientIp } from '../../services/auditLog'; +import { getClientIp } from '../audit/client-ip'; /** * /api/integrations/memories/immich — Immich connection, browse/search, asset diff --git a/server/src/nest/oauth/oauth-api.controller.ts b/server/src/nest/oauth/oauth-api.controller.ts index 0a761124e..21c1d1b27 100644 --- a/server/src/nest/oauth/oauth-api.controller.ts +++ b/server/src/nest/oauth/oauth-api.controller.ts @@ -6,7 +6,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CookieAuthGuard } from '../auth/cookie-auth.guard'; import { OptionalJwtGuard } from '../auth/optional-jwt.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { getClientIp } from '../../services/auditLog'; +import { getClientIp } from '../audit/client-ip'; import type { User } from '../../types'; import type { AuthorizeParams } from '../../services/oauthService'; diff --git a/server/src/nest/oauth/oauth-public.controller.ts b/server/src/nest/oauth/oauth-public.controller.ts index 9a05d0767..e5e6c03ec 100644 --- a/server/src/nest/oauth/oauth-public.controller.ts +++ b/server/src/nest/oauth/oauth-public.controller.ts @@ -2,7 +2,9 @@ import { Controller, Get, Headers, HttpCode, Post, Req, Res } from '@nestjs/comm import type { Request, Response } from 'express'; import { OauthService } from './oauth.service'; import { RateLimitService } from '../auth/rate-limit.service'; -import { writeAudit, getClientIp, logWarn } from '../../services/auditLog'; +import { getClientIp } from '../audit/client-ip'; +import { logWarn } from '../audit/audit-log.logger'; +import { AuditService } from '../audit/audit.service'; const MIN = 60_000; @@ -16,7 +18,7 @@ const MIN = 60_000; */ @Controller('oauth') export class OauthPublicController { - constructor(private readonly oauth: OauthService, private readonly rl: RateLimitService) {} + constructor(private readonly oauth: OauthService, private readonly rl: RateLimitService, private readonly audit: AuditService) {} @Post('token') @HttpCode(200) // token success uses res.json without an explicit status; Express defaults to 200 (Nest POST would default to 201). @@ -47,7 +49,7 @@ export class OauthPublicController { } const pending = this.oauth.consumeAuthCode(code); const invalidGrant = (reason: string, userId: number | null) => { - writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip }); + this.audit.writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip }); res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); }; if (!pending) return invalidGrant('code_invalid_or_expired', null); @@ -56,13 +58,13 @@ export class OauthPublicController { if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) return invalidGrant('resource_mismatch', pending.userId); if (!this.oauth.authenticateClient(client_id, client_secret)) { logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`); - writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip }); + this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip }); res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' }); return; } if (!this.oauth.verifyPKCE(code_verifier, pending.codeChallenge)) return invalidGrant('pkce_failed', pending.userId); const tokens = this.oauth.issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null); - writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip }); + this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip }); res.json(tokens); return; } @@ -90,12 +92,12 @@ export class OauthPublicController { const client = this.oauth.authenticateClient(client_id, client_secret); if (!client) { logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`); - writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip }); + this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip }); res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' }); return; } if (client.is_public || !client.allows_client_credentials || client.user_id == null) { - writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip }); + this.audit.writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip }); res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' }); return; } @@ -114,7 +116,7 @@ export class OauthPublicController { } const audience = resource ? resource.replace(/\/+$/, '') : `${this.oauth.mcpSafeUrl().replace(/\/+$/, '')}/mcp`; const tokens = this.oauth.issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience); - writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip }); + this.audit.writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip }); res.json(tokens); return; } @@ -155,7 +157,7 @@ export class OauthPublicController { } if (!this.oauth.authenticateClient(client_id, client_secret)) { logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`); - writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip }); + this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip }); res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' }); return; } diff --git a/server/src/nest/oauth/oauth.module.ts b/server/src/nest/oauth/oauth.module.ts index ebebe01a3..5bff52955 100644 --- a/server/src/nest/oauth/oauth.module.ts +++ b/server/src/nest/oauth/oauth.module.ts @@ -3,6 +3,7 @@ import { OauthPublicController } from './oauth-public.controller'; import { OauthApiController } from './oauth-api.controller'; import { OauthService } from './oauth.service'; import { RateLimitService } from '../auth/rate-limit.service'; +import { AuditModule } from '../audit/audit.module'; /** * OAuth 2.1 server (MCP). Public token/userinfo/revoke endpoints + the SPA's @@ -11,6 +12,7 @@ import { RateLimitService } from '../auth/rate-limit.service'; * strangler lists /oauth/token, /oauth/userinfo, /oauth/revoke explicitly. */ @Module({ + imports: [AuditModule], controllers: [OauthPublicController, OauthApiController], providers: [OauthService, RateLimitService], }) diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 3ceb28cdc..1fa088167 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -26,7 +26,7 @@ import { pluginCodeDir, pluginDataDir } from './paths'; import { assertHostCompatible, PluginRegistryService, RegistryError } from './registry/registry.service'; import { hostSatisfies, hostVersion } from './install/host-compat'; import { keyFingerprint } from './signature-status'; -import { writeAudit } from '../../services/auditLog'; +import { AuditService } from '../audit/audit.service'; import { isAddonEnabled } from '../../services/adminService'; import type { PluginDependency } from './install/manifest'; import type { VersionMismatch, PluginDepRow } from './dependencies'; @@ -161,9 +161,11 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // The registry and host-deps factory stay optional at the type level so tests // can construct the service without them; Nest always injects the real ones - // (both providers are in the module). + // (both providers are in the module). audit sits before the optionals + // because a required param cannot follow optional ones. constructor( private readonly dbs: DatabaseService, + private readonly audit: AuditService, private readonly registry?: PluginRegistryService, private readonly hostDeps?: PluginHostDepsFactory, ) {} @@ -720,7 +722,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // reconstructible after an incident. This goes to the ADMIN audit log, not the // plugin capability log — that one answers "what have plugins done in my name?" // and is shown to end users; a lifecycle action by an admin does not belong there. - writeAudit({ + this.audit.writeAudit({ userId: actor.userId, action: 'admin.plugin_retrust', resource: id, diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index df33b497b..7813fb784 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -8,7 +8,7 @@ import { PluginRegistryService, RegistryError } from './registry/registry.servic import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AdminGuard } from '../auth/admin.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { getClientIp } from '../../services/auditLog'; +import { getClientIp } from '../audit/client-ip'; import { pluginsEnabled } from './kill-switch'; import { devLinkEnabled } from './dev-link'; diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index 6d838b661..86195e456 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -37,6 +37,7 @@ import { FilesModule } from '../files/files.module'; import { CollabModule } from '../collab/collab.module'; import { VacayModule } from '../vacay/vacay.module'; import { PermissionsModule } from '../permissions/permissions.module'; +import { AuditModule } from '../audit/audit.module'; /** * Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend: @@ -48,7 +49,7 @@ import { PermissionsModule } from '../permissions/permissions.module'; @Module({ // The DI-native domain services the plugin host wiring injects // (PluginHostDepsFactory); DatabaseModule is @Global, so not listed. - imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, PermissionsModule], + imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, PermissionsModule, AuditModule], controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController], providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory], // Exported so the admin addon-toggle handler can cascade-disable plugins whose diff --git a/server/src/nest/trip-invite/trip-invite.controller.ts b/server/src/nest/trip-invite/trip-invite.controller.ts index 76831cec4..808d2a648 100644 --- a/server/src/nest/trip-invite/trip-invite.controller.ts +++ b/server/src/nest/trip-invite/trip-invite.controller.ts @@ -6,7 +6,8 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { RateLimitService } from '../auth/rate-limit.service'; import { TripInviteLinkCreateDto } from './trip-invite.dto'; -import { writeAudit, getClientIp } from '../../services/auditLog'; +import { getClientIp } from '../audit/client-ip'; +import { AuditService } from '../audit/audit.service'; const RL_WINDOW = 15 * 60 * 1000; @@ -20,7 +21,7 @@ const RL_WINDOW = 15 * 60 * 1000; @Controller('api/trips/:tripId/invite-link') @UseGuards(JwtAuthGuard) export class TripInviteLinkController { - constructor(private readonly invites: TripInviteService) {} + constructor(private readonly invites: TripInviteService, private readonly audit: AuditService) {} private requireManage(tripId: string, user: User) { const trip = this.invites.verifyTripAccess(tripId, user.id); @@ -49,7 +50,7 @@ export class TripInviteLinkController { ? parseInt(String(body.expires_in_days)) : null; const info = this.invites.createOrRotate(tripId, user.id, Number.isFinite(days as number) ? days : null); - writeAudit({ userId: user.id, action: 'trip.invite_link_create', resource: tripId, ip: getClientIp(req), details: { expires_in_days: days } }); + this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_create', resource: tripId, ip: getClientIp(req), details: { expires_in_days: days } }); return info; } @@ -57,7 +58,7 @@ export class TripInviteLinkController { remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) { this.requireManage(tripId, user); this.invites.remove(tripId); - writeAudit({ userId: user.id, action: 'trip.invite_link_delete', resource: tripId, ip: getClientIp(req) }); + this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_delete', resource: tripId, ip: getClientIp(req) }); return { success: true }; } } @@ -72,7 +73,7 @@ export class TripInviteLinkController { @Controller('api/trip-invites') @UseGuards(JwtAuthGuard) export class TripInviteController { - constructor(private readonly invites: TripInviteService, private readonly rl: RateLimitService) {} + constructor(private readonly invites: TripInviteService, private readonly rl: RateLimitService, private readonly audit: AuditService) {} private limit(req: Request, max: number): void { if (!this.rl.check('trip_invite', req.ip || 'unknown', max, RL_WINDOW, Date.now())) { @@ -95,7 +96,7 @@ export class TripInviteController { const resolved = this.invites.resolve(token); if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404); const result = this.invites.join(resolved.trip_id, user.id); - writeAudit({ userId: user.id, action: 'trip.invite_link_join', resource: String(resolved.trip_id), ip: getClientIp(req), details: { joined: result.joined } }); + this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_join', resource: String(resolved.trip_id), ip: getClientIp(req), details: { joined: result.joined } }); return { trip_id: resolved.trip_id, joined: result.joined }; } } diff --git a/server/src/nest/trip-invite/trip-invite.module.ts b/server/src/nest/trip-invite/trip-invite.module.ts index fcc55bce2..cf170b936 100644 --- a/server/src/nest/trip-invite/trip-invite.module.ts +++ b/server/src/nest/trip-invite/trip-invite.module.ts @@ -3,9 +3,10 @@ import { TripInviteLinkController, TripInviteController } from './trip-invite.co import { TripInviteService } from './trip-invite.service'; import { RateLimitService } from '../auth/rate-limit.service'; import { PermissionsModule } from '../permissions/permissions.module'; +import { AuditModule } from '../audit/audit.module'; @Module({ - imports: [PermissionsModule], + imports: [PermissionsModule, AuditModule], controllers: [TripInviteLinkController, TripInviteController], providers: [TripInviteService, RateLimitService], }) diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index 345dde26a..7ad5160f8 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -27,7 +27,9 @@ import type { User } from '../../types'; import { TripsService } from './trips.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { writeAudit, getClientIp, logInfo } from '../../services/auditLog'; +import { getClientIp } from '../audit/client-ip'; +import { logInfo } from '../audit/audit-log.logger'; +import { AuditService } from '../audit/audit.service'; import { isDemoEmail } from '../../services/demo'; import { NotFoundError, ValidationError } from '../../services/tripService'; import { saveUnsplashCover, isUnsplashCoverUrl } from '../../services/unsplashService'; @@ -66,7 +68,7 @@ const addDays = (d: Date, n: number) => { const r = new Date(d); r.setDate(r.get @Controller('api/trips') @UseGuards(JwtAuthGuard) export class TripsController { - constructor(private readonly trips: TripsService) {} + constructor(private readonly trips: TripsService, private readonly audit: AuditService) {} @Get() list(@CurrentUser() user: User, @Query('archived') archived?: string) { @@ -107,7 +109,7 @@ export class TripsController { } const parsedDayCount = day_count ? Math.min(Math.max(Number(day_count) || 7, 1), 365) : undefined; const { trip, tripId, reminderDays } = this.trips.create(user.id, { title, description, start_date, end_date, currency, reminder_days, day_count: parsedDayCount }); - writeAudit({ userId: user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } }); + this.audit.writeAudit({ userId: user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } }); if (reminderDays > 0) logInfo(`${user.email} set ${reminderDays}-day reminder for trip "${title}"`); return { trip }; } @@ -159,7 +161,7 @@ export class TripsController { this.trips.deleteOldCover(oldCover); } if (Object.keys(result.changes).length > 0) { - writeAudit({ userId: user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } }); + this.audit.writeAudit({ userId: user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } }); if (result.isAdminEdit && result.ownerEmail) logInfo(`Admin ${user.email} edited trip "${result.newTitle}" owned by ${result.ownerEmail}`); } if (result.newReminder !== result.oldReminder) { @@ -212,7 +214,7 @@ export class TripsController { } try { const newTripId = this.trips.copy(id, user.id, title); - writeAudit({ userId: user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(id), newTripId, title } }); + this.audit.writeAudit({ userId: user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(id), newTripId, title } }); return { trip: this.trips.getCopiedTrip(newTripId, user.id) }; } catch { throw new HttpException({ error: 'Failed to copy trip' }, 500); @@ -229,7 +231,7 @@ export class TripsController { throw new HttpException({ error: 'No permission to delete this trip' }, 403); } const info = this.trips.remove(id, user.id, user.role); - writeAudit({ userId: user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) } }); + this.audit.writeAudit({ userId: user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) } }); if (info.isAdminDelete && info.ownerEmail) logInfo(`Admin ${user.email} deleted trip "${info.title}" owned by ${info.ownerEmail}`); this.trips.broadcast(String(info.tripId), 'trip:deleted', { id: info.tripId }, socketId); return { success: true }; @@ -302,7 +304,7 @@ export class TripsController { } try { const result = this.trips.transferOwnership(id, newOwnerId, user.id); - writeAudit({ userId: user.id, action: 'trip.transfer_ownership', ip: getClientIp(req), details: { tripId: Number(id), trip: result.tripTitle, from: result.fromEmail, to: result.toEmail } }); + this.audit.writeAudit({ userId: user.id, action: 'trip.transfer_ownership', ip: getClientIp(req), details: { tripId: Number(id), trip: result.tripTitle, from: result.fromEmail, to: result.toEmail } }); // Nudge everyone viewing the trip to re-read it so the new ownership and the // recomputed permissions take effect live. const updatedTrip = this.trips.get(id, user.id); diff --git a/server/src/nest/trips/trips.module.ts b/server/src/nest/trips/trips.module.ts index da1c2da59..14439357b 100644 --- a/server/src/nest/trips/trips.module.ts +++ b/server/src/nest/trips/trips.module.ts @@ -5,13 +5,14 @@ import { PackingModule } from '../packing/packing.module'; import { ReservationsModule } from '../reservations/reservations.module'; import { TodoModule } from '../todo/todo.module'; import { PermissionsModule } from '../permissions/permissions.module'; +import { AuditModule } from '../audit/audit.module'; import { TripsController } from './trips.controller'; import { TripsService } from './trips.service'; /** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does * not capture the nested sub-domain mounts (collab, files, ...). */ @Module({ - imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule], + imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule], controllers: [TripsController], providers: [TripsService], }) diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index a1cc1b8d8..5d815b85d 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -3,7 +3,7 @@ import archiver from 'archiver'; import { readEnv } from './app-config'; import path from 'node:path'; import fs from 'node:fs'; -import { logInfo, logError } from './services/auditLog'; +import { logInfo, logError } from './nest/audit/audit-log.logger'; const dataDir = path.join(__dirname, '../data'); const backupsDir = path.join(dataDir, 'backups'); diff --git a/server/src/services/airtrail/airtrailService.ts b/server/src/services/airtrail/airtrailService.ts index 8938ee4f0..b8d2ebafd 100644 --- a/server/src/services/airtrail/airtrailService.ts +++ b/server/src/services/airtrail/airtrailService.ts @@ -2,7 +2,7 @@ import type { AirtrailFlight } from '@trek/shared'; import { db } from '../../db/database'; import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; import { checkSsrf } from '../../utils/ssrfGuard'; -import { writeAudit } from '../auditLog'; +import { writeAudit } from '../../nest/audit/audit.bridge'; import { AirtrailAuthError, AirtrailCreds, AirtrailRequestError, listFlights } from './airtrailClient'; import { normalizeFlight } from './airtrailMapper'; diff --git a/server/src/services/airtrail/airtrailSync.ts b/server/src/services/airtrail/airtrailSync.ts index 0472467e5..4fd3ac69d 100644 --- a/server/src/services/airtrail/airtrailSync.ts +++ b/server/src/services/airtrail/airtrailSync.ts @@ -2,7 +2,7 @@ import { ADDON_IDS } from '../../addons'; import { db } from '../../db/database'; import { broadcast } from '../../websocket'; import { isAddonEnabled } from '../adminService'; -import { logError, logInfo } from '../auditLog'; +import { logError, logInfo } from '../../nest/audit/audit-log.logger'; import { getReservation, getReservationWithJoins, updateReservation } from '../../nest/reservations/reservations.bridge'; import { AirtrailAuthError, diff --git a/server/src/services/auditLog.ts b/server/src/services/auditLog.ts deleted file mode 100644 index e8d5e09e9..000000000 --- a/server/src/services/auditLog.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { Request } from 'express'; -import { readEnv } from '../app-config'; -import { db } from '../db/database'; -import fs from 'fs'; -import path from 'path'; - -// Frozen at import on purpose (legacy timing; tests/setup.ts sets it pre-import). -const LOG_LEVEL = (readEnv().app.logLevel || 'info').toLowerCase(); -const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB -const MAX_LOG_FILES = 5; - -const C = { - blue: '\x1b[34m', - cyan: '\x1b[36m', - red: '\x1b[31m', - yellow: '\x1b[33m', - reset: '\x1b[0m', -}; - -// ── File logger with rotation ───────────────────────────────────────────── - -const logsDir = path.join(process.cwd(), 'data/logs'); -try { fs.mkdirSync(logsDir, { recursive: true }); } catch {} -const logFilePath = path.join(logsDir, 'trek.log'); - -function rotateIfNeeded(): void { - try { - if (!fs.existsSync(logFilePath)) return; - const stat = fs.statSync(logFilePath); - if (stat.size < MAX_LOG_SIZE) return; - - for (let i = MAX_LOG_FILES - 1; i >= 1; i--) { - const src = i === 1 ? logFilePath : `${logFilePath}.${i - 1}`; - const dst = `${logFilePath}.${i}`; - if (fs.existsSync(src)) fs.renameSync(src, dst); - } - } catch {} -} - -function writeToFile(line: string): void { - try { - rotateIfNeeded(); - fs.appendFileSync(logFilePath, line + '\n'); - } catch {} -} - -// ── Public log helpers ──────────────────────────────────────────────────── - -function formatTs(): string { - const tz = readEnv().app.tz || 'UTC'; - return new Date().toLocaleString('sv-SE', { timeZone: tz }).replace(' ', 'T'); -} - -function logInfo(msg: string): void { - const ts = formatTs(); - console.log(`${C.blue}[INFO]${C.reset} ${ts} ${msg}`); - writeToFile(`[INFO] ${ts} ${msg}`); -} - -function logDebug(msg: string): void { - if (LOG_LEVEL !== 'debug') return; - const ts = formatTs(); - console.log(`${C.cyan}[DEBUG]${C.reset} ${ts} ${msg}`); - writeToFile(`[DEBUG] ${ts} ${msg}`); -} - -function logError(msg: string): void { - const ts = formatTs(); - console.error(`${C.red}[ERROR]${C.reset} ${ts} ${msg}`); - writeToFile(`[ERROR] ${ts} ${msg}`); -} - -function logWarn(msg: string): void { - const ts = formatTs(); - console.warn(`${C.yellow}[WARN]${C.reset} ${ts} ${msg}`); - writeToFile(`[WARN] ${ts} ${msg}`); -} - -// ── IP + audit ──────────────────────────────────────────────────────────── - -export function getClientIp(req: Request): string | null { - const xff = req.headers['x-forwarded-for']; - if (typeof xff === 'string') { - const first = xff.split(',')[0]?.trim(); - return first || null; - } - if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null; - return req.socket?.remoteAddress || null; -} - -function resolveUserEmail(userId: number | null): string { - if (!userId) return 'anonymous'; - try { - const row = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined; - return row?.email || `uid:${userId}`; - } catch { return `uid:${userId}`; } -} - -const ACTION_LABELS: Record = { - 'user.register': 'registered', - 'user.login': 'logged in', - 'user.login_failed': 'login failed', - 'user.password_change': 'changed password', - 'user.account_delete': 'deleted account', - 'user.mfa_enable': 'enabled MFA', - 'user.mfa_disable': 'disabled MFA', - 'settings.app_update': 'updated settings', - 'trip.create': 'created trip', - 'trip.delete': 'deleted trip', - 'admin.user_role_change': 'changed user role', - 'admin.user_delete': 'deleted user', - 'admin.plugin_retrust': "re-trusted a plugin's author signing key", - 'admin.invite_create': 'created invite', - 'immich.private_ip_configured': 'configured Immich with private IP', -}; - -/** Best-effort; never throws — failures are logged only. */ -export function writeAudit(entry: { - userId: number | null; - action: string; - resource?: string | null; - details?: Record; - debugDetails?: Record; - ip?: string | null; -}): void { - try { - const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null; - db.prepare( - `INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)` - ).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null); - - const email = resolveUserEmail(entry.userId); - const label = ACTION_LABELS[entry.action] || entry.action; - const brief = buildInfoSummary(entry.action, entry.details); - logInfo(`${email} ${label}${brief} ip=${entry.ip || '-'}`); - - if (entry.debugDetails && Object.keys(entry.debugDetails).length > 0) { - logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${JSON.stringify(entry.debugDetails)}`); - } else if (detailsJson) { - logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${detailsJson}`); - } - } catch (e) { - logError(`Audit write failed: ${e instanceof Error ? e.message : e}`); - } -} - -function buildInfoSummary(action: string, details?: Record): string { - if (!details || Object.keys(details).length === 0) return ''; - if (action === 'trip.create') return ` "${details.title}"`; - if (action === 'trip.delete') return ` tripId=${details.tripId}`; - if (action === 'user.register') return ` ${details.email}`; - if (action === 'user.login') return ''; - if (action === 'user.login_failed') return ` reason=${details.reason}`; - if (action === 'settings.app_update') { - const parts: string[] = []; - if (details.notification_channel) parts.push(`channel=${details.notification_channel}`); - if (details.smtp_settings_updated) parts.push('smtp'); - if (details.notification_events_updated) parts.push('events'); - if (details.webhook_url_updated) parts.push('webhook_url'); - if (details.allowed_file_types_updated) parts.push('file_types'); - if (details.allow_registration !== undefined) parts.push(`registration=${details.allow_registration}`); - if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`); - return parts.length ? ` (${parts.join(', ')})` : ''; - } - if (action === 'immich.private_ip_configured') { - return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : ''; - } - return ''; -} - -export { LOG_LEVEL, logInfo, logDebug, logError, logWarn }; diff --git a/server/src/services/memories/immichService.ts b/server/src/services/memories/immichService.ts index 4932a3288..9e797e1b0 100644 --- a/server/src/services/memories/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -2,7 +2,7 @@ import { Response } from 'express'; import { db } from '../../db/database'; import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; import { checkSsrf, safeFetch } from '../../utils/ssrfGuard'; -import { writeAudit } from '../auditLog'; +import { writeAudit } from '../../nest/audit/audit.bridge'; import { addTripPhotos} from './unifiedService'; import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection, pipeAsset } from './helpersService'; diff --git a/server/src/services/notificationService.ts b/server/src/services/notificationService.ts index b5fb2cae2..b43d94ffa 100644 --- a/server/src/services/notificationService.ts +++ b/server/src/services/notificationService.ts @@ -1,6 +1,6 @@ import { readEnv } from '../app-config'; import { db } from '../db/database'; -import { logDebug, logError } from './auditLog'; +import { logDebug, logError } from '../nest/audit/audit-log.logger'; import { getActiveChannels, isEnabledForEvent, diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index ddf5e7c8f..c484a988a 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -2,7 +2,7 @@ import { readEnv } from '../app-config'; import { db } from '../db/database'; import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard'; import { decrypt_api_key } from './apiKeyCrypto'; -import { logInfo, logDebug, logError } from './auditLog'; +import { logInfo, logDebug, logError } from '../nest/audit/audit-log.logger'; // ── Types ────────────────────────────────────────────────────────────────── import type { NotifEventType } from './notificationPreferencesService'; diff --git a/server/src/services/oauthService.ts b/server/src/services/oauthService.ts index f65db7554..bf46a0efa 100644 --- a/server/src/services/oauthService.ts +++ b/server/src/services/oauthService.ts @@ -4,7 +4,7 @@ import { isAddonEnabled } from './adminService'; import { validateScopes } from '../mcp/scopes'; import { ADDON_IDS } from '../addons'; import { User } from '../types'; -import { writeAudit, logWarn } from './auditLog'; +import { writeAudit, logWarn } from '../nest/audit/audit.bridge'; import { revokeUserSessionsForClient } from '../mcp/sessionManager'; import { getMcpSafeUrl } from './notifications'; diff --git a/server/tests/e2e/admin.e2e.test.ts b/server/tests/e2e/admin.e2e.test.ts index d63c600f6..74b0cdc4b 100644 --- a/server/tests/e2e/admin.e2e.test.ts +++ b/server/tests/e2e/admin.e2e.test.ts @@ -24,11 +24,18 @@ const { db } = vi.hoisted(() => { tmp.exec(`CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, UNIQUE(user_id, key));`); tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);'); + // AuditService now runs its real INSERT (DI-injected, no mock) — slim + // audit_log mirror (no FKs), same shape as plugin-runtime.test.ts. + tmp.exec(`CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER, action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT);`); return { db: tmp }; }); vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); -vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '1.2.3.4', logInfo: vi.fn() })); +// The audit domain is DI-native now: writeAudit runs for real against the temp +// db's audit_log table; only the file logger is silenced. +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); vi.mock('../../src/mcp', () => ({ invalidateMcpSessions: vi.fn() })); vi.mock('../../src/services/notificationPreferencesService', () => ({ getPreferencesMatrix: vi.fn(() => ({})), setAdminPreferences: vi.fn() })); vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) })); diff --git a/server/tests/e2e/auth.e2e.test.ts b/server/tests/e2e/auth.e2e.test.ts index 0625bb731..2d3d58bc3 100644 --- a/server/tests/e2e/auth.e2e.test.ts +++ b/server/tests/e2e/auth.e2e.test.ts @@ -20,11 +20,18 @@ const { db } = vi.hoisted(() => { tmp.exec('PRAGMA journal_mode = WAL'); tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); + // AuditService now runs its real INSERT (DI-injected, no mock) — slim + // audit_log mirror (no FKs), same shape as plugin-runtime.test.ts. + tmp.exec(`CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER, action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT);`); return { db: tmp }; }); vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); -vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') })); +// The audit domain is DI-native now: writeAudit runs for real against the temp +// db's audit_log table; only the file logger is silenced. +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); vi.mock('../../src/services/notifications', () => ({ getAppUrl: () => 'https://x', sendPasswordResetEmail: vi.fn().mockResolvedValue({ delivered: true }) })); const { authSvc } = vi.hoisted(() => ({ @@ -41,6 +48,7 @@ const { authSvc } = vi.hoisted(() => ({ vi.mock('../../src/services/authService', () => authSvc); import { AuthModule } from '../../src/nest/auth/auth.module'; +import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; describe('Auth e2e (real auth guard + real cookie service + temp SQLite)', () => { @@ -48,7 +56,7 @@ describe('Auth e2e (real auth guard + real cookie service + temp SQLite)', () => let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [AuthModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AuthModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/backup.e2e.test.ts b/server/tests/e2e/backup.e2e.test.ts index a8e80ea26..7238311fa 100644 --- a/server/tests/e2e/backup.e2e.test.ts +++ b/server/tests/e2e/backup.e2e.test.ts @@ -18,11 +18,18 @@ const { db } = vi.hoisted(() => { tmp.exec('PRAGMA journal_mode = WAL'); tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); + // AuditService now runs its real INSERT (DI-injected, no mock) — slim + // audit_log mirror (no FKs), same shape as plugin-runtime.test.ts. + tmp.exec(`CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER, action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT);`); return { db: tmp }; }); vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); -vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') })); +// The audit domain is DI-native now: writeAudit runs for real against the temp +// db's audit_log table; only the file logger is silenced. +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); const { backupSvc } = vi.hoisted(() => ({ backupSvc: { @@ -35,6 +42,7 @@ const { backupSvc } = vi.hoisted(() => ({ vi.mock('../../src/services/backupService', () => backupSvc); import { BackupModule } from '../../src/nest/backup/backup.module'; +import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; describe('Backup e2e (real auth + admin guard + temp SQLite)', () => { @@ -42,7 +50,7 @@ describe('Backup e2e (real auth + admin guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [BackupModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, BackupModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/oauth.e2e.test.ts b/server/tests/e2e/oauth.e2e.test.ts index 4cb89f81d..4465a611e 100644 --- a/server/tests/e2e/oauth.e2e.test.ts +++ b/server/tests/e2e/oauth.e2e.test.ts @@ -19,11 +19,18 @@ const { db } = vi.hoisted(() => { tmp.exec('PRAGMA journal_mode = WAL'); tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); + // AuditService now runs its real INSERT (DI-injected, no mock) — slim + // audit_log mirror (no FKs), same shape as plugin-runtime.test.ts. + tmp.exec(`CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER, action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT);`); return { db: tmp }; }); vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); -vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '1.2.3.4', logWarn: vi.fn() })); +// The audit domain is DI-native now: writeAudit runs for real against the temp +// db's audit_log table; only the file logger is silenced. +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); vi.mock('../../src/services/notifications', () => ({ getMcpSafeUrl: () => 'https://app' })); const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); @@ -41,6 +48,7 @@ const { oauthSvc } = vi.hoisted(() => ({ vi.mock('../../src/services/oauthService', () => oauthSvc); import { OauthModule } from '../../src/nest/oauth/oauth.module'; +import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; describe('OAuth e2e (real guards + temp SQLite)', () => { @@ -48,7 +56,7 @@ describe('OAuth e2e (real guards + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [OauthModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, OauthModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/trip-invite.e2e.test.ts b/server/tests/e2e/trip-invite.e2e.test.ts index 0fb0a46c8..856c989f4 100644 --- a/server/tests/e2e/trip-invite.e2e.test.ts +++ b/server/tests/e2e/trip-invite.e2e.test.ts @@ -31,6 +31,11 @@ const { db } = vi.hoisted(() => { created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, expires_at TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + // AuditService now runs its real INSERT (DI-injected, no mock) — slim + // audit_log mirror (no FKs), same shape as plugin-runtime.test.ts. + tmp.exec(`CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER, action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT);`); return { db: tmp }; }); @@ -48,7 +53,9 @@ let checkPermission: MockInstance; const { joinTripAsMember } = vi.hoisted(() => ({ joinTripAsMember: vi.fn() })); vi.mock('../../src/services/tripMembership', () => ({ joinTripAsMember })); -vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '127.0.0.1' })); +// The audit domain is DI-native now: writeAudit runs for real against the temp +// db's audit_log table; only the file logger is silenced. +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); import { TripInviteModule } from '../../src/nest/trip-invite/trip-invite.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index 7c5a4466e..1e9c1b631 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -66,6 +66,11 @@ const { db } = vi.hoisted(() => { timezone TEXT, local_time TEXT, local_date TEXT);`); tmp.exec(`CREATE TABLE reservation_travelers (reservation_id INTEGER NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (reservation_id, user_id));`); + // AuditService now runs its real INSERT (DI-injected, no mock) — slim + // audit_log mirror (no FKs), same shape as plugin-runtime.test.ts. + tmp.exec(`CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER, action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT);`); return { db: tmp }; }); @@ -75,7 +80,9 @@ vi.mock('../../src/db/database', () => ({ })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) })); -vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn(), logError: vi.fn() })); +// The audit domain is DI-native now: writeAudit runs for real against the temp +// db's audit_log table; only the file logger is silenced. +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); vi.mock('../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) })); import { PermissionsService } from '../../src/nest/permissions/permissions.service'; @@ -148,6 +155,9 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { const ok = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'T' }); expect(ok.status).toBe(201); expect(ok.body).toEqual({ trip: { id: 9 } }); + // The DI-native AuditService wrote the real row (audit_log DDL above). + const audit = db.prepare("SELECT user_id FROM audit_log WHERE action = 'trip.create'").get() as { user_id: number }; + expect(audit).toEqual({ user_id: 1 }); checkPermission.mockReturnValue(false); const forbidden = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'T' }); expect(forbidden.status).toBe(403); diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 7d8443915..5b7501d2b 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -18,6 +18,7 @@ import { FilesService } from '../../src/nest/files/files.service'; import { CollabService } from '../../src/nest/collab/collab.service'; import { VacayService } from '../../src/nest/vacay/vacay.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; +import { AuditService } from '../../src/nest/audit/audit.service'; /** * Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests @@ -49,5 +50,5 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto /** A PluginRuntimeService constructed the way Nest would: with a real host-deps factory. */ export function createPluginRuntime(dbs: DatabaseService, registry?: PluginRegistryService): PluginRuntimeService { - return new PluginRuntimeService(dbs, registry, createHostDepsFactory(dbs)); + return new PluginRuntimeService(dbs, new AuditService(dbs), registry, createHostDepsFactory(dbs)); } diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index 2884de67d..d2c494906 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -2,13 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { HttpException, NotFoundException } from '@nestjs/common'; import type { Request } from 'express'; -vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn() })); +vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') })); +vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) })); import { AdminController } from '../../../src/nest/admin/admin.controller'; import type { AdminService } from '../../../src/nest/admin/admin.service'; import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; -import { writeAudit } from '../../../src/services/auditLog'; +import type { AuditService } from '../../../src/nest/audit/audit.service'; import { send as sendNotification } from '../../../src/services/notificationService'; import type { User } from '../../../src/types'; @@ -18,6 +19,13 @@ const req = { headers: {} } as Request; function svc(o: Partial = {}): AdminService { return { invalidateMcpSessions: vi.fn(), ...o } as unknown as AdminService; } + +// AuditService is constructor-injected since the auditLog DI migration; the +// wrapper keeps the historical construction sites positional. +const writeAudit = vi.fn(); +const audit = { writeAudit } as unknown as AuditService; +const adminCtl = (s: AdminService, rt?: PluginRuntimeService) => + new AdminController(s, rt as unknown as PluginRuntimeService, audit); function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { if (err instanceof NotFoundException) return { status: 404, body: err.getResponse() }; @@ -33,64 +41,64 @@ afterEach(() => { delete process.env.NODE_ENV; }); describe('AdminController users', () => { it('lists, creates (201 + audit), maps an error', () => { - expect(new AdminController(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listUsers()).toEqual({ users: [{ id: 1 }] }); - expect(thrown(() => new AdminController(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } }); - const c = new AdminController(svc({ createUser: vi.fn().mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }) } as Partial)); + expect(adminCtl(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listUsers()).toEqual({ users: [{ id: 1 }] }); + expect(thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } }); + const c = adminCtl(svc({ createUser: vi.fn().mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }) } as Partial)); expect(c.createUser(user, { email: 'a@b.c' }, req)).toEqual({ user: { id: 2 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.user_create' })); }); it('update + delete audit and map errors', () => { - expect(new AdminController(svc({ updateUser: vi.fn().mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }) } as Partial)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } }); - expect(thrown(() => new AdminController(svc({ deleteUser: vi.fn().mockReturnValue({ error: 'Cannot delete self', status: 400 }) } as Partial)).deleteUser(user, '1', req))).toEqual({ status: 400, body: { error: 'Cannot delete self' } }); - expect(new AdminController(svc({ deleteUser: vi.fn().mockReturnValue({ email: 'a@b.c' }) } as Partial)).deleteUser(user, '2', req)).toEqual({ success: true }); + expect(adminCtl(svc({ updateUser: vi.fn().mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }) } as Partial)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } }); + expect(thrown(() => adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ error: 'Cannot delete self', status: 400 }) } as Partial)).deleteUser(user, '1', req))).toEqual({ status: 400, body: { error: 'Cannot delete self' } }); + expect(adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ email: 'a@b.c' }) } as Partial)).deleteUser(user, '2', req)).toEqual({ success: true }); }); }); describe('AdminController permissions + oidc + misc', () => { it('permissions: 400 without an object, else saves + audits', () => { - expect(thrown(() => new AdminController(svc()).savePermissions(user, {}, req))).toEqual({ status: 400, body: { error: 'permissions object required' } }); - const c = new AdminController(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: { x: 1 }, skipped: [] }) } as Partial)); + expect(thrown(() => adminCtl(svc()).savePermissions(user, {}, req))).toEqual({ status: 400, body: { error: 'permissions object required' } }); + const c = adminCtl(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: { x: 1 }, skipped: [] }) } as Partial)); expect(c.savePermissions(user, { permissions: { x: 1 } }, req)).toEqual({ success: true, permissions: { x: 1 } }); }); it('permissions: includes skipped when present', () => { - const c = new AdminController(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: {}, skipped: ['bad'] }) } as Partial)); + const c = adminCtl(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: {}, skipped: ['bad'] }) } as Partial)); expect(c.savePermissions(user, { permissions: {} }, req)).toEqual({ success: true, permissions: {}, skipped: ['bad'] }); }); it('oidc update maps error, else audits', () => { - expect(thrown(() => new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'bad issuer', status: 400 }) } as Partial)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'bad issuer' } }); - expect(new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial)).updateOidc(user, { issuer: 'https://idp' }, req)).toEqual({ success: true }); + expect(thrown(() => adminCtl(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'bad issuer', status: 400 }) } as Partial)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'bad issuer' } }); + expect(adminCtl(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial)).updateOidc(user, { issuer: 'https://idp' }, req)).toEqual({ success: true }); }); it('save-demo-baseline maps error, else returns message', () => { - expect(thrown(() => new AdminController(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ error: 'not demo', status: 400 }) } as Partial)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } }); - expect(new AdminController(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial)).saveDemoBaseline(user, req)).toEqual({ success: true, message: 'saved' }); + expect(thrown(() => adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ error: 'not demo', status: 400 }) } as Partial)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } }); + expect(adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial)).saveDemoBaseline(user, req)).toEqual({ success: true, message: 'saved' }); }); }); describe('AdminController invites + feature toggles', () => { it('invites: create 201 + audit, delete maps error', () => { - const c = new AdminController(svc({ createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) } as Partial)); + const c = adminCtl(svc({ createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) } as Partial)); expect(c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.invite_create' })); - expect(thrown(() => new AdminController(svc({ deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial)).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } }); + expect(thrown(() => adminCtl(svc({ deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial)).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } }); }); it('places-photos: 400 on a non-boolean, else updates + audits', () => { - expect(thrown(() => new AdminController(svc()).updatePlacesPhotos(user, { enabled: 'yes' }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } }); - expect(new AdminController(svc({ updatePlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).updatePlacesPhotos(user, { enabled: true }, req)).toEqual({ enabled: true }); + expect(thrown(() => adminCtl(svc()).updatePlacesPhotos(user, { enabled: 'yes' }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } }); + expect(adminCtl(svc({ updatePlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).updatePlacesPhotos(user, { enabled: true }, req)).toEqual({ enabled: true }); }); it('collab-features update invalidates MCP sessions only when a flag actually flipped (#1414)', () => { const invalidateMcpSessions = vi.fn(); - const c = new AdminController(svc({ updateCollabFeatures: vi.fn().mockReturnValue({ features: { chat: true }, changed: true }), invalidateMcpSessions } as Partial)); + const c = adminCtl(svc({ updateCollabFeatures: vi.fn().mockReturnValue({ features: { chat: true }, changed: true }), invalidateMcpSessions } as Partial)); expect(c.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true }); expect(invalidateMcpSessions).toHaveBeenCalled(); const noopInvalidate = vi.fn(); - const noop = new AdminController(svc({ updateCollabFeatures: vi.fn().mockReturnValue({ features: { chat: true }, changed: false }), invalidateMcpSessions: noopInvalidate } as Partial)); + const noop = adminCtl(svc({ updateCollabFeatures: vi.fn().mockReturnValue({ features: { chat: true }, changed: false }), invalidateMcpSessions: noopInvalidate } as Partial)); expect(noop.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true }); expect(noopInvalidate).not.toHaveBeenCalled(); }); @@ -98,10 +106,10 @@ describe('AdminController invites + feature toggles', () => { describe('AdminController packing templates', () => { it('get 404, create 201, delete audits', () => { - expect(thrown(() => new AdminController(svc({ getPackingTemplate: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial)).getPackingTemplate('9'))).toEqual({ status: 404, body: { error: 'not found' } }); - expect(new AdminController(svc({ createPackingTemplate: vi.fn().mockReturnValue({ id: 3, name: 'Beach' }) } as Partial)).createPackingTemplate(user, { name: 'Beach' })).toEqual({ id: 3, name: 'Beach' }); - expect(new AdminController(svc({ deletePackingTemplate: vi.fn().mockReturnValue({ name: 'Beach' }) } as Partial)).deletePackingTemplate(user, '3', req)).toEqual({ success: true }); - expect(new AdminController(svc({ createTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial)).createTemplateItem('3', '4', { name: 'Towel' })).toEqual({ id: 7 }); + expect(thrown(() => adminCtl(svc({ getPackingTemplate: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial)).getPackingTemplate('9'))).toEqual({ status: 404, body: { error: 'not found' } }); + expect(adminCtl(svc({ createPackingTemplate: vi.fn().mockReturnValue({ id: 3, name: 'Beach' }) } as Partial)).createPackingTemplate(user, { name: 'Beach' })).toEqual({ id: 3, name: 'Beach' }); + expect(adminCtl(svc({ deletePackingTemplate: vi.fn().mockReturnValue({ name: 'Beach' }) } as Partial)).deletePackingTemplate(user, '3', req)).toEqual({ success: true }); + expect(adminCtl(svc({ createTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial)).createTemplateItem('3', '4', { name: 'Towel' })).toEqual({ id: 7 }); }); }); @@ -109,34 +117,34 @@ describe('AdminController addons + sessions + jwt + defaults', () => { it('addon update audits + invalidates MCP sessions only when the MCP surface changed (#1414)', async () => { const invalidateMcpSessions = vi.fn(); const runtime = { deactivateForDisabledAddon: vi.fn() } as unknown as PluginRuntimeService; - const c = new AdminController(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'mcp', enabled: true }, mcpAffected: true, auditDetails: {} }), invalidateMcpSessions } as Partial), runtime); + const c = adminCtl(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'mcp', enabled: true }, mcpAffected: true, auditDetails: {} }), invalidateMcpSessions } as Partial), runtime); expect(await c.updateAddon(user, 'mcp', { enabled: true }, req)).toEqual({ addon: { id: 'mcp', enabled: true } }); expect(invalidateMcpSessions).toHaveBeenCalled(); expect(runtime.deactivateForDisabledAddon).not.toHaveBeenCalled(); // Config-only saves / MCP-irrelevant addons keep live sessions alive. const noopInvalidate = vi.fn(); - const noop = new AdminController(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'llm_parsing', enabled: true }, mcpAffected: false, auditDetails: {} }), invalidateMcpSessions: noopInvalidate } as Partial), runtime); + const noop = adminCtl(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'llm_parsing', enabled: true }, mcpAffected: false, auditDetails: {} }), invalidateMcpSessions: noopInvalidate } as Partial), runtime); expect(await noop.updateAddon(user, 'llm_parsing', { config: { model: 'x' } }, req)).toEqual({ addon: { id: 'llm_parsing', enabled: true } }); expect(noopInvalidate).not.toHaveBeenCalled(); // Disabling an addon cascades to the plugin runtime. const runtime2 = { deactivateForDisabledAddon: vi.fn().mockResolvedValue(['p1']) } as unknown as PluginRuntimeService; - const disable = new AdminController(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'budget', enabled: false }, mcpAffected: true, auditDetails: {} }), invalidateMcpSessions: vi.fn() } as Partial), runtime2); + const disable = adminCtl(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'budget', enabled: false }, mcpAffected: true, auditDetails: {} }), invalidateMcpSessions: vi.fn() } as Partial), runtime2); await disable.updateAddon(user, 'budget', { enabled: false }, req); expect(runtime2.deactivateForDisabledAddon).toHaveBeenCalledWith('budget'); }); it('oauth-sessions revoke audits; rotate-jwt maps error', () => { - expect(new AdminController(svc({ revokeOAuthSession: vi.fn().mockReturnValue({}) } as Partial)).revokeOAuthSession(user, '3', req)).toEqual({ success: true }); - expect(thrown(() => new AdminController(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } }); - expect(new AdminController(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial)).rotateJwtSecret(user, req)).toEqual({ success: true }); + expect(adminCtl(svc({ revokeOAuthSession: vi.fn().mockReturnValue({}) } as Partial)).revokeOAuthSession(user, '3', req)).toEqual({ success: true }); + expect(thrown(() => adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } }); + expect(adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial)).rotateJwtSecret(user, req)).toEqual({ success: true }); }); it('default-user-settings: 400 on a non-object, else sets + audits', () => { - expect(thrown(() => new AdminController(svc()).setDefaultUserSettings(user, [], req))).toEqual({ status: 400, body: { error: 'Object body required' } }); + expect(thrown(() => adminCtl(svc()).setDefaultUserSettings(user, [], req))).toEqual({ status: 400, body: { error: 'Object body required' } }); const setAdminUserDefaults = vi.fn(); - const c = new AdminController(svc({ setAdminUserDefaults, getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial)); + const c = adminCtl(svc({ setAdminUserDefaults, getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial)); expect(c.setDefaultUserSettings(user, { theme: 'dark' }, req)).toEqual({ theme: 'dark' }); expect(setAdminUserDefaults).toHaveBeenCalled(); }); @@ -144,51 +152,51 @@ describe('AdminController addons + sessions + jwt + defaults', () => { describe('AdminController error envelope fallbacks', () => { it('ok() defaults to 400 when the error envelope omits a status', () => { - expect(thrown(() => new AdminController(svc({ createUser: vi.fn().mockReturnValue({ error: 'boom' }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 400, body: { error: 'boom' } }); + expect(thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'boom' }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 400, body: { error: 'boom' } }); }); it('updateOidc defaults to 400 when the service error omits a status', () => { - expect(thrown(() => new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'nope' }) } as Partial)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'nope' } }); + expect(thrown(() => adminCtl(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'nope' }) } as Partial)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'nope' } }); }); it('updateOidc audits issuer_set=false when no issuer is supplied', () => { - expect(new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial)).updateOidc(user, {}, req)).toEqual({ success: true }); + expect(adminCtl(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial)).updateOidc(user, {}, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.oidc_update', details: { issuer_set: false } })); }); }); describe('AdminController read-only getters', () => { it('return service values verbatim', () => { - expect(new AdminController(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 }); - expect(new AdminController(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial)).stats()).toEqual({ users: 3 }); - expect(new AdminController(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial)).permissions()).toEqual({ a: 1 }); - expect(new AdminController(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial)).auditLog({})).toEqual({ entries: [] }); - expect(new AdminController(svc({ getOidcSettings: vi.fn().mockReturnValue({ issuer: 'x' }) } as Partial)).getOidc()).toEqual({ issuer: 'x' }); - expect(new AdminController(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial)).versionCheck()).resolves.toEqual({ current: '1' }); - expect(new AdminController(svc({ getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [] }) } as Partial)).getNotificationPrefs(user)).toEqual({ rows: [] }); - expect(new AdminController(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listInvites()).toEqual({ invites: [{ id: 1 }] }); - expect(new AdminController(svc({ getBagTracking: vi.fn().mockReturnValue({ enabled: false }) } as Partial)).getBagTracking()).toEqual({ enabled: false }); - expect(new AdminController(svc({ getPlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).getPlacesPhotos()).toEqual({ enabled: true }); - expect(new AdminController(svc({ getPlacesAutocomplete: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).getPlacesAutocomplete()).toEqual({ enabled: true }); - expect(new AdminController(svc({ getPlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).getPlacesDetails()).toEqual({ enabled: true }); - expect(new AdminController(svc({ getCollabFeatures: vi.fn().mockReturnValue({ chat: false }) } as Partial)).getCollabFeatures()).toEqual({ chat: false }); - expect(new AdminController(svc({ listPackingTemplates: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listPackingTemplates()).toEqual({ templates: [{ id: 1 }] }); - expect(new AdminController(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] }); - expect(new AdminController(svc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] }); - expect(new AdminController(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] }); - expect(new AdminController(svc({ getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial)).getDefaultUserSettings()).toEqual({ theme: 'dark' }); + expect(adminCtl(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 }); + expect(adminCtl(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial)).stats()).toEqual({ users: 3 }); + expect(adminCtl(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial)).permissions()).toEqual({ a: 1 }); + expect(adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial)).auditLog({})).toEqual({ entries: [] }); + expect(adminCtl(svc({ getOidcSettings: vi.fn().mockReturnValue({ issuer: 'x' }) } as Partial)).getOidc()).toEqual({ issuer: 'x' }); + expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial)).versionCheck()).resolves.toEqual({ current: '1' }); + expect(adminCtl(svc({ getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [] }) } as Partial)).getNotificationPrefs(user)).toEqual({ rows: [] }); + expect(adminCtl(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listInvites()).toEqual({ invites: [{ id: 1 }] }); + expect(adminCtl(svc({ getBagTracking: vi.fn().mockReturnValue({ enabled: false }) } as Partial)).getBagTracking()).toEqual({ enabled: false }); + expect(adminCtl(svc({ getPlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).getPlacesPhotos()).toEqual({ enabled: true }); + expect(adminCtl(svc({ getPlacesAutocomplete: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).getPlacesAutocomplete()).toEqual({ enabled: true }); + expect(adminCtl(svc({ getPlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).getPlacesDetails()).toEqual({ enabled: true }); + expect(adminCtl(svc({ getCollabFeatures: vi.fn().mockReturnValue({ chat: false }) } as Partial)).getCollabFeatures()).toEqual({ chat: false }); + expect(adminCtl(svc({ listPackingTemplates: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listPackingTemplates()).toEqual({ templates: [{ id: 1 }] }); + expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] }); + expect(adminCtl(svc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] }); + expect(adminCtl(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] }); + expect(adminCtl(svc({ getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial)).getDefaultUserSettings()).toEqual({ theme: 'dark' }); }); it('setNotificationPrefs persists then returns the refreshed matrix', () => { const setAdminPreferences = vi.fn(); - const c = new AdminController(svc({ setAdminPreferences, getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [1] }) } as Partial)); + const c = adminCtl(svc({ setAdminPreferences, getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [1] }) } as Partial)); expect(c.setNotificationPrefs(user, { x: 1 })).toEqual({ rows: [1] }); expect(setAdminPreferences).toHaveBeenCalledWith(user.id, { x: 1 }); }); it('githubReleases falls back to default paging when no query is given', async () => { const getGithubReleases = vi.fn().mockResolvedValue([{ tag: 'v1' }]); - const c = new AdminController(svc({ getGithubReleases } as Partial)); + const c = adminCtl(svc({ getGithubReleases } as Partial)); await expect(c.githubReleases()).resolves.toEqual([{ tag: 'v1' }]); expect(getGithubReleases).toHaveBeenCalledWith('10', '1'); await c.githubReleases('5', '2'); @@ -198,73 +206,73 @@ describe('AdminController read-only getters', () => { describe('AdminController feature toggles + audit', () => { it('bag-tracking updates and audits', () => { - const c = new AdminController(svc({ updateBagTracking: vi.fn().mockReturnValue({ enabled: true }) } as Partial)); + const c = adminCtl(svc({ updateBagTracking: vi.fn().mockReturnValue({ enabled: true }) } as Partial)); expect(c.updateBagTracking(user, { enabled: true }, req)).toEqual({ enabled: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.bag_tracking' })); }); it('places-autocomplete: 400 on a non-boolean, else updates + audits', () => { - expect(thrown(() => new AdminController(svc()).updatePlacesAutocomplete(user, { enabled: 'yes' }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } }); - expect(new AdminController(svc({ updatePlacesAutocomplete: vi.fn().mockReturnValue({ enabled: false }) } as Partial)).updatePlacesAutocomplete(user, { enabled: false }, req)).toEqual({ enabled: false }); + expect(thrown(() => adminCtl(svc()).updatePlacesAutocomplete(user, { enabled: 'yes' }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } }); + expect(adminCtl(svc({ updatePlacesAutocomplete: vi.fn().mockReturnValue({ enabled: false }) } as Partial)).updatePlacesAutocomplete(user, { enabled: false }, req)).toEqual({ enabled: false }); }); it('places-details: 400 on a non-boolean, else updates + audits', () => { - expect(thrown(() => new AdminController(svc()).updatePlacesDetails(user, { enabled: 1 }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } }); - expect(new AdminController(svc({ updatePlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).updatePlacesDetails(user, { enabled: true }, req)).toEqual({ enabled: true }); + expect(thrown(() => adminCtl(svc()).updatePlacesDetails(user, { enabled: 1 }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } }); + expect(adminCtl(svc({ updatePlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).updatePlacesDetails(user, { enabled: true }, req)).toEqual({ enabled: true }); }); }); describe('AdminController packing template sub-routes', () => { it('update/delete templates, categories and items map errors + return success', () => { - expect(new AdminController(svc({ updatePackingTemplate: vi.fn().mockReturnValue({ id: 3 }) } as Partial)).updatePackingTemplate('3', {})).toEqual({ id: 3 }); - expect(new AdminController(svc({ createTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial)).createTemplateCategory('3', { name: 'Tops' })).toEqual({ id: 4 }); - expect(new AdminController(svc({ updateTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial)).updateTemplateCategory('3', '4', {})).toEqual({ id: 4 }); - expect(new AdminController(svc({ deleteTemplateCategory: vi.fn().mockReturnValue({}) } as Partial)).deleteTemplateCategory('3', '4')).toEqual({ success: true }); - expect(new AdminController(svc({ updateTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial)).updateTemplateItem('7', {})).toEqual({ id: 7 }); - expect(new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({}) } as Partial)).deleteTemplateItem('7')).toEqual({ success: true }); - expect(thrown(() => new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({ error: 'gone', status: 404 }) } as Partial)).deleteTemplateItem('9'))).toEqual({ status: 404, body: { error: 'gone' } }); + expect(adminCtl(svc({ updatePackingTemplate: vi.fn().mockReturnValue({ id: 3 }) } as Partial)).updatePackingTemplate('3', {})).toEqual({ id: 3 }); + expect(adminCtl(svc({ createTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial)).createTemplateCategory('3', { name: 'Tops' })).toEqual({ id: 4 }); + expect(adminCtl(svc({ updateTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial)).updateTemplateCategory('3', '4', {})).toEqual({ id: 4 }); + expect(adminCtl(svc({ deleteTemplateCategory: vi.fn().mockReturnValue({}) } as Partial)).deleteTemplateCategory('3', '4')).toEqual({ success: true }); + expect(adminCtl(svc({ updateTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial)).updateTemplateItem('7', {})).toEqual({ id: 7 }); + expect(adminCtl(svc({ deleteTemplateItem: vi.fn().mockReturnValue({}) } as Partial)).deleteTemplateItem('7')).toEqual({ success: true }); + expect(thrown(() => adminCtl(svc({ deleteTemplateItem: vi.fn().mockReturnValue({ error: 'gone', status: 404 }) } as Partial)).deleteTemplateItem('9'))).toEqual({ status: 404, body: { error: 'gone' } }); }); }); describe('AdminController tokens + sessions', () => { it('mcp token + oauth session deletes return success and map errors', () => { - expect(new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial)).deleteMcpToken('2')).toEqual({ success: true }); - expect(thrown(() => new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) } as Partial)).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } }); - expect(thrown(() => new AdminController(svc({ revokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) } as Partial)).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } }); + expect(adminCtl(svc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial)).deleteMcpToken('2')).toEqual({ success: true }); + expect(thrown(() => adminCtl(svc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) } as Partial)).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } }); + expect(thrown(() => adminCtl(svc({ revokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) } as Partial)).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } }); }); }); describe('AdminController default-user-settings error path', () => { it('400 with an Error message when setAdminUserDefaults throws an Error', () => { - const c = new AdminController(svc({ setAdminUserDefaults: vi.fn(() => { throw new Error('bad default'); }) } as Partial)); + const c = adminCtl(svc({ setAdminUserDefaults: vi.fn(() => { throw new Error('bad default'); }) } as Partial)); expect(thrown(() => c.setDefaultUserSettings(user, { theme: 'x' }, req))).toEqual({ status: 400, body: { error: 'bad default' } }); }); it('400 stringifies a non-Error throw', () => { - const c = new AdminController(svc({ setAdminUserDefaults: vi.fn(() => { throw 'plain string'; }) } as Partial)); + const c = adminCtl(svc({ setAdminUserDefaults: vi.fn(() => { throw 'plain string'; }) } as Partial)); expect(thrown(() => c.setDefaultUserSettings(user, { theme: 'x' }, req))).toEqual({ status: 400, body: { error: 'plain string' } }); }); it('400 when the body is null', () => { - expect(thrown(() => new AdminController(svc()).setDefaultUserSettings(user, null, req))).toEqual({ status: 400, body: { error: 'Object body required' } }); + expect(thrown(() => adminCtl(svc()).setDefaultUserSettings(user, null, req))).toEqual({ status: 400, body: { error: 'Object body required' } }); }); }); describe('AdminController dev test-notification', () => { it('404 outside development', async () => { delete process.env.NODE_ENV; - await expect(new AdminController(svc()).devTestNotification(user, {})).rejects.toBeInstanceOf(NotFoundException); + await expect(adminCtl(svc()).devTestNotification(user, {})).rejects.toBeInstanceOf(NotFoundException); }); it('sends in development', async () => { process.env.NODE_ENV = 'development'; - const res = await new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' }); + const res = await adminCtl(svc()).devTestNotification(user, { event: 'trip_reminder' }); expect(res).toEqual({ success: true }); }); it('applies notification defaults when the body is empty', async () => { process.env.NODE_ENV = 'development'; - const res = await new AdminController(svc()).devTestNotification(user, {}); + const res = await adminCtl(svc()).devTestNotification(user, {}); expect(res).toEqual({ success: true }); expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ event: 'trip_reminder', scope: 'user', targetId: user.id })); }); @@ -272,12 +280,12 @@ describe('AdminController dev test-notification', () => { it('maps an Error from the notification service to 400', async () => { process.env.NODE_ENV = 'development'; (sendNotification as unknown as ReturnType).mockRejectedValueOnce(new Error('send failed')); - await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'send failed' } }); + await expect(adminCtl(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'send failed' } }); }); it('stringifies a non-Error notification failure to 400', async () => { process.env.NODE_ENV = 'development'; (sendNotification as unknown as ReturnType).mockRejectedValueOnce('weird'); - await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'weird' } }); + await expect(adminCtl(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'weird' } }); }); }); diff --git a/server/tests/unit/nest/audit-log.logger.test.ts b/server/tests/unit/nest/audit-log.logger.test.ts new file mode 100644 index 000000000..d7aa9b67a --- /dev/null +++ b/server/tests/unit/nest/audit-log.logger.test.ts @@ -0,0 +1,110 @@ +/** + * Unit tests for nest/audit/audit-log.logger — AUDIT-LOG-001 through + * AUDIT-LOG-007. The logger is the deliberately side-effectful plain module + * carved out of the legacy services/auditLog.ts (frozen LOG_LEVEL, import-time + * mkdir, 10 MB × 5 rotation); it sits inside the src/nest/** coverage gate, so + * its branches are pinned here with fs fully mocked. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('fs', () => { + const mock = { + mkdirSync: vi.fn(), + existsSync: vi.fn(() => false), + statSync: vi.fn(() => ({ size: 0 })), + appendFileSync: vi.fn(), + renameSync: vi.fn(), + }; + return { default: mock, ...mock }; +}); + +import fs from 'fs'; +import { logInfo, logDebug, logError, logWarn, LOG_LEVEL } from '../../../src/nest/audit/audit-log.logger'; + +const mocked = vi.mocked(fs); + +beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + mocked.existsSync.mockReturnValue(false); + mocked.statSync.mockReturnValue({ size: 0 } as unknown as ReturnType); +}); + +describe('log levels + line format', () => { + it('AUDIT-LOG-001: logInfo writes the ANSI console line and the plain file line', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + logInfo('hello'); + expect(log).toHaveBeenCalledTimes(1); + const consoleLine = String(log.mock.calls[0][0]); + expect(consoleLine.startsWith('\x1b[34m[INFO]\x1b[0m ')).toBe(true); + expect(consoleLine).toMatch(/ \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} hello$/); + expect(mocked.appendFileSync).toHaveBeenCalledTimes(1); + expect(String(mocked.appendFileSync.mock.calls[0][1])).toMatch(/^\[INFO\] \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} hello\n$/); + }); + + it('AUDIT-LOG-002: logError/logWarn use console.error/console.warn with their tags', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + logError('boom'); + logWarn('careful'); + expect(err.mock.calls[0][0]).toContain('[ERROR]'); + expect(warn.mock.calls[0][0]).toContain('[WARN]'); + expect(String(mocked.appendFileSync.mock.calls[0][1])).toMatch(/^\[ERROR\] /); + expect(String(mocked.appendFileSync.mock.calls[1][1])).toMatch(/^\[WARN\] /); + }); + + it('AUDIT-LOG-003: logDebug is gated by the import-frozen LOG_LEVEL (tests/setup.ts sets "error")', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + expect(LOG_LEVEL).toBe('error'); + logDebug('invisible'); + expect(log).not.toHaveBeenCalled(); + expect(mocked.appendFileSync).not.toHaveBeenCalled(); + }); + + it('AUDIT-LOG-004: the freeze happens at first import — a debug-env reimport logs debug lines', async () => { + vi.resetModules(); + vi.stubEnv('LOG_LEVEL', 'debug'); + try { + const fresh = await import('../../../src/nest/audit/audit-log.logger'); + expect(fresh.LOG_LEVEL).toBe('debug'); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + fresh.logDebug('now visible'); + expect(log).toHaveBeenCalledTimes(1); + expect(log.mock.calls[0][0]).toContain('[DEBUG]'); + } finally { + vi.unstubAllEnvs(); + vi.resetModules(); + } + }); +}); + +describe('rotation + resilience', () => { + it('AUDIT-LOG-005: rotates trek.log through .1… in descending order once the cap is hit', () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + mocked.existsSync.mockReturnValue(true); + mocked.statSync.mockReturnValue({ size: 10 * 1024 * 1024 } as unknown as ReturnType); + logInfo('rotate me'); + // MAX_LOG_FILES=5 → renames .3→.4, .2→.3, .1→.2, trek.log→.1 (descending). + expect(mocked.renameSync).toHaveBeenCalledTimes(4); + const dsts = mocked.renameSync.mock.calls.map((c) => String(c[1])); + expect(dsts[0].endsWith('trek.log.4')).toBe(true); + expect(dsts[3].endsWith('trek.log.1')).toBe(true); + expect(String(mocked.renameSync.mock.calls[3][0]).endsWith('trek.log')).toBe(true); + }); + + it('AUDIT-LOG-006: no rotation below the size cap', () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + mocked.existsSync.mockReturnValue(true); + mocked.statSync.mockReturnValue({ size: 10 } as unknown as ReturnType); + logInfo('small'); + expect(mocked.renameSync).not.toHaveBeenCalled(); + expect(mocked.appendFileSync).toHaveBeenCalledTimes(1); + }); + + it('AUDIT-LOG-007: file-IO failures are swallowed (bare catch) — the console line still prints', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + mocked.appendFileSync.mockImplementation(() => { throw new Error('disk full'); }); + expect(() => logInfo('still up')).not.toThrow(); + expect(log).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/tests/unit/nest/audit.service.test.ts b/server/tests/unit/nest/audit.service.test.ts new file mode 100644 index 000000000..dec5904ca --- /dev/null +++ b/server/tests/unit/nest/audit.service.test.ts @@ -0,0 +1,200 @@ +/** + * Unit tests for the DI-native audit domain — AUDIT-SVC-001 through + * AUDIT-SVC-017 (001–007 are the getClientIp cases moved 1:1 from the legacy + * tests/unit/services/auditLog.test.ts, which had no case IDs — the IDs are + * introduced with the move; 008–014 cover writeAudit over a real in-memory + * SQLite DB, which the legacy suite never exercised; 015–017 pin the + * audit.bridge delegation). The logger module is mocked (it replaces the old + * suite's fs mock: the import-time mkdir lives there now, and mocking it lets + * the exact log-line formats be asserted). + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +// ── DB setup ────────────────────────────────────────────────────────────────── + +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: () => {}, + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ + LOG_LEVEL: 'error', + logInfo: vi.fn(), + logDebug: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), +})); + +import type { Request } from 'express'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { AuditService } from '../../../src/nest/audit/audit.service'; +import { getClientIp } from '../../../src/nest/audit/client-ip'; +import { logInfo, logDebug, logError } from '../../../src/nest/audit/audit-log.logger'; +import { writeAudit as bridgeWriteAudit, getClientIp as bridgeGetClientIp, logInfo as bridgeLogInfo } from '../../../src/nest/audit/audit.bridge'; + +const svc = new AuditService(new DatabaseService(testDb)); + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + vi.clearAllMocks(); + testDb.prepare('DELETE FROM audit_log').run(); + testDb.prepare('DELETE FROM users').run(); +}); + +afterAll(() => { + testDb.close(); +}); + +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; +} + +// ── getClientIp (pure, moved 1:1) ───────────────────────────────────────────── + +describe('getClientIp', () => { + it('AUDIT-SVC-001: 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('AUDIT-SVC-002: returns single IP when X-Forwarded-For has no comma', () => { + expect(getClientIp(makeReq({ xff: '10.0.0.1' }))).toBe('10.0.0.1'); + }); + + it('AUDIT-SVC-003: 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('AUDIT-SVC-004: trims whitespace from extracted IP', () => { + expect(getClientIp(makeReq({ xff: ' 192.168.1.1 , 10.0.0.1' }))).toBe('192.168.1.1'); + }); + + it('AUDIT-SVC-005: 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('AUDIT-SVC-006: returns null when no forwarded header and no socket address', () => { + expect(getClientIp(makeReq({}))).toBeNull(); + }); + + it('AUDIT-SVC-007: 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(); + }); +}); + +// ── writeAudit (real DB) ────────────────────────────────────────────────────── + +function seedUser(id: number, email: string): void { + testDb.prepare( + "INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, 'x', 'user')" + ).run(id, `u${id}`, email); +} + +describe('writeAudit', () => { + it('AUDIT-SVC-008: inserts the row and logs the labeled summary line', () => { + seedUser(1, 'a@b.c'); + svc.writeAudit({ userId: 1, action: 'trip.create', resource: 'trip', details: { title: 'Rome' }, ip: '1.2.3.4' }); + const row = testDb.prepare('SELECT user_id, action, resource, details, ip FROM audit_log').get(); + expect(row).toEqual({ user_id: 1, action: 'trip.create', resource: 'trip', details: '{"title":"Rome"}', ip: '1.2.3.4' }); + expect(logInfo).toHaveBeenCalledWith('a@b.c created trip "Rome" ip=1.2.3.4'); + }); + + it('AUDIT-SVC-009: empty details object stores NULL details and skips the debug fallback', () => { + seedUser(1, 'a@b.c'); + svc.writeAudit({ userId: 1, action: 'user.login', details: {}, ip: '1.2.3.4' }); + const row = testDb.prepare('SELECT details FROM audit_log').get() as { details: string | null }; + expect(row.details).toBeNull(); + expect(logDebug).not.toHaveBeenCalled(); + }); + + it('AUDIT-SVC-010: unknown action keeps the raw key; unknown/zero/null userIds degrade', () => { + seedUser(1, 'a@b.c'); + svc.writeAudit({ userId: 1, action: 'custom.thing', ip: '9.9.9.9' }); + expect(logInfo).toHaveBeenLastCalledWith('a@b.c custom.thing ip=9.9.9.9'); + seedUser(42, ''); // falsy email → the `row?.email || uid:N` fallback + svc.writeAudit({ userId: 42, action: 'user.login', ip: '9.9.9.9' }); + expect(logInfo).toHaveBeenLastCalledWith('uid:42 logged in ip=9.9.9.9'); + seedUser(0, 'zero@b.c'); // id 0 is falsy → audits as anonymous despite the real email + svc.writeAudit({ userId: 0, action: 'user.login', ip: '9.9.9.9' }); + expect(logInfo).toHaveBeenLastCalledWith('anonymous logged in ip=9.9.9.9'); + svc.writeAudit({ userId: null, action: 'user.login', ip: '9.9.9.9' }); + expect(logInfo).toHaveBeenLastCalledWith('anonymous logged in ip=9.9.9.9'); + }); + + it('AUDIT-SVC-011: omitted resource/ip store NULL and the log line ends ip=-', () => { + seedUser(1, 'a@b.c'); + svc.writeAudit({ userId: 1, action: 'user.login' }); + const row = testDb.prepare('SELECT resource, ip FROM audit_log').get(); + expect(row).toEqual({ resource: null, ip: null }); + expect(logInfo).toHaveBeenCalledWith('a@b.c logged in ip=-'); + }); + + it('AUDIT-SVC-012: debugDetails wins the debug line; detailsJson is the fallback', () => { + seedUser(1, 'a@b.c'); + svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { require_mfa: true }, debugDetails: { raw: 1 } }); + expect(logDebug).toHaveBeenLastCalledWith('AUDIT settings.app_update userId=1 {"raw":1}'); + svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { require_mfa: true } }); + expect(logDebug).toHaveBeenLastCalledWith('AUDIT settings.app_update userId=1 {"require_mfa":true}'); + }); + + it('AUDIT-SVC-013: never throws — a failed insert reduces to a logError line', () => { + testDb.prepare('ALTER TABLE audit_log RENAME TO audit_log_gone').run(); + expect(() => svc.writeAudit({ userId: 1, action: 'user.login' })).not.toThrow(); + expect(logError).toHaveBeenCalledWith(expect.stringMatching(/^Audit write failed: /)); + testDb.prepare('ALTER TABLE audit_log_gone RENAME TO audit_log').run(); + }); + + it('AUDIT-SVC-014: buildInfoSummary variants (settings parts, login empty brief)', () => { + seedUser(1, 'a@b.c'); + svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { notification_channel: 'smtp', require_mfa: false }, ip: '1.1.1.1' }); + expect(logInfo).toHaveBeenLastCalledWith('a@b.c updated settings (channel=smtp, mfa=false) ip=1.1.1.1'); + svc.writeAudit({ userId: 1, action: 'user.login', details: { anything: true }, ip: '1.1.1.1' }); + expect(logInfo).toHaveBeenLastCalledWith('a@b.c logged in ip=1.1.1.1'); + }); +}); + +// ── audit.bridge delegation ─────────────────────────────────────────────────── + +describe('audit.bridge delegation', () => { + it('AUDIT-SVC-015: writeAudit writes a real row through the module-level instance', () => { + seedUser(1, 'a@b.c'); + bridgeWriteAudit({ userId: 1, action: 'user.login', ip: '5.5.5.5' }); + const row = testDb.prepare('SELECT user_id, action, ip FROM audit_log').get(); + expect(row).toEqual({ user_id: 1, action: 'user.login', ip: '5.5.5.5' }); + }); + + it('AUDIT-SVC-016: getClientIp is the same pure helper', () => { + expect(bridgeGetClientIp).toBe(getClientIp); + }); + + it('AUDIT-SVC-017: the log helpers re-export the logger module', () => { + expect(bridgeLogInfo).toBe(logInfo); + }); +}); diff --git a/server/tests/unit/nest/auth-guard.test.ts b/server/tests/unit/nest/auth-guard.test.ts index 0f5223d4b..8bb25c8da 100644 --- a/server/tests/unit/nest/auth-guard.test.ts +++ b/server/tests/unit/nest/auth-guard.test.ts @@ -5,7 +5,8 @@ import type { Request } from 'express'; vi.mock('../../../src/middleware/auth', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() })); vi.mock('../../../src/services/authService', () => ({ resolveAuthToggles: vi.fn() })); vi.mock('../../../src/services/cookie', () => ({ setAuthCookie: vi.fn() })); -vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') })); +vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') })); +vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); vi.mock('../../../src/services/passkeyService', () => ({ passkeyRegisterOptions: vi.fn(), passkeyRegisterVerify: vi.fn(), @@ -23,11 +24,17 @@ import { AdminGuard } from '../../../src/nest/auth/admin.guard'; import { PasskeyEnabledGuard } from '../../../src/nest/auth/passkey-enabled.guard'; import { PasskeyController } from '../../../src/nest/auth/passkey.controller'; import { RateLimitService } from '../../../src/nest/auth/rate-limit.service'; + +// AuditService is constructor-injected since the auditLog DI migration; the +// wrapper keeps the historical construction sites positional. +const writeAudit = vi.fn(); +const audit = { writeAudit } as unknown as AuditService; +const pc = (limiter: RateLimitService) => new PasskeyController(limiter, audit); import { CurrentUser } from '../../../src/nest/auth/current-user.decorator'; import { extractToken, verifyJwtAndLoadUser } from '../../../src/middleware/auth'; import { resolveAuthToggles } from '../../../src/services/authService'; import { setAuthCookie } from '../../../src/services/cookie'; -import { writeAudit } from '../../../src/services/auditLog'; +import type { AuditService } from '../../../src/nest/audit/audit.service'; import * as passkey from '../../../src/services/passkeyService'; import type { User } from '../../../src/types'; @@ -196,52 +203,52 @@ describe('PasskeyController', () => { it('register/options maps a service error, else returns the options', async () => { vi.mocked(passkey.passkeyRegisterOptions).mockResolvedValue({ error: 'Incorrect password', status: 401 }); - expect(await thrownAsync(() => new PasskeyController(rl()).registerOptions(user, { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); + expect(await thrownAsync(() => pc(rl()).registerOptions(user, { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); vi.mocked(passkey.passkeyRegisterOptions).mockResolvedValue({ options: { challenge: 'c' } as never }); - expect(await new PasskeyController(rl()).registerOptions(user, { password: 'p' }, req)).toEqual({ challenge: 'c' }); + expect(await pc(rl()).registerOptions(user, { password: 'p' }, req)).toEqual({ challenge: 'c' }); }); it('register/verify maps a service error, else audits and returns the credential', async () => { vi.mocked(passkey.passkeyRegisterVerify).mockResolvedValue({ error: 'Verification failed', status: 400 } as never); - expect(await thrownAsync(() => new PasskeyController(rl()).registerVerify(user, {}, req))).toEqual({ status: 400, body: { error: 'Verification failed' } }); + expect(await thrownAsync(() => pc(rl()).registerVerify(user, {}, req))).toEqual({ status: 400, body: { error: 'Verification failed' } }); vi.mocked(passkey.passkeyRegisterVerify).mockResolvedValue({ credential: { id: 'cr' } } as never); - expect(await new PasskeyController(rl()).registerVerify(user, {}, req)).toEqual({ success: true, credential: { id: 'cr' } }); + expect(await pc(rl()).registerVerify(user, {}, req)).toEqual({ success: true, credential: { id: 'cr' } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_register' })); }); it('login/options maps a service error, else returns the options', async () => { vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ error: 'Not configured', status: 503 } as never); - expect(await thrownAsync(() => new PasskeyController(rl()).loginOptions(req))).toEqual({ status: 503, body: { error: 'Not configured' } }); + expect(await thrownAsync(() => pc(rl()).loginOptions(req))).toEqual({ status: 503, body: { error: 'Not configured' } }); vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ options: { challenge: 'd' } } as never); - expect(await new PasskeyController(rl()).loginOptions(req)).toEqual({ challenge: 'd' }); + expect(await pc(rl()).loginOptions(req)).toEqual({ challenge: 'd' }); }); it('login/verify audits a failure then maps the error, padding latency', async () => { vi.mocked(passkey.passkeyLoginVerify).mockResolvedValue({ error: 'No match', status: 401, auditAction: 'user.login_fail', auditUserId: null } as never); - expect(await thrownAsync(() => new PasskeyController(rl()).loginVerify({}, req, res))).toEqual({ status: 401, body: { error: 'No match' } }); + expect(await thrownAsync(() => pc(rl()).loginVerify({}, req, res))).toEqual({ status: 401, body: { error: 'No match' } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_fail' })); }, 10000); it('login/verify sets the session cookie and audits login on success', async () => { vi.mocked(passkey.passkeyLoginVerify).mockResolvedValue({ token: 'tk', user, auditUserId: 1 } as never); - expect(await new PasskeyController(rl()).loginVerify({}, req, res)).toEqual({ token: 'tk', user }); + expect(await pc(rl()).loginVerify({}, req, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login', details: { method: 'passkey' } })); }, 10000); it('credentials: list, rename (error + success), delete (error + success)', () => { vi.mocked(passkey.listPasskeys).mockReturnValue([{ id: 'a' }]); - expect(new PasskeyController(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] }); + expect(pc(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] }); vi.mocked(passkey.renamePasskey).mockReturnValue({ error: 'Not found', status: 404 }); - expect(thrown(() => new PasskeyController(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({ status: 404, body: { error: 'Not found' } }); + expect(thrown(() => pc(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({ status: 404, body: { error: 'Not found' } }); vi.mocked(passkey.renamePasskey).mockReturnValue({ success: true }); - expect(new PasskeyController(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true }); + expect(pc(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true }); vi.mocked(passkey.deletePasskey).mockReturnValue({ error: 'Incorrect password', status: 401 }); - expect(thrown(() => new PasskeyController(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); + expect(thrown(() => pc(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); vi.mocked(passkey.deletePasskey).mockReturnValue({ success: true }); - expect(new PasskeyController(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true }); + expect(pc(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_delete' })); }); @@ -249,16 +256,16 @@ describe('PasskeyController', () => { const s = new RateLimitService(); const now = Date.now(); for (let i = 0; i < 5; i++) s.check('mfa', '9.9.9.9', 5, 15 * 60 * 1000, now); - expect(await thrownAsync(() => new PasskeyController(s).registerOptions(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + expect(await thrownAsync(() => pc(s).registerOptions(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); const s2 = new RateLimitService(); for (let i = 0; i < 10; i++) s2.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now); - expect(await thrownAsync(() => new PasskeyController(s2).loginOptions(req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + expect(await thrownAsync(() => pc(s2).loginOptions(req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('falls back to the "unknown" rate-limit key when req.ip is absent', async () => { vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ options: { challenge: 'z' } } as never); const noIp = {} as Request; - expect(await new PasskeyController(rl()).loginOptions(noIp)).toEqual({ challenge: 'z' }); + expect(await pc(rl()).loginOptions(noIp)).toEqual({ challenge: 'z' }); }); }); diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts index faef3ee59..d81150e06 100644 --- a/server/tests/unit/nest/auth.controller.test.ts +++ b/server/tests/unit/nest/auth.controller.test.ts @@ -2,14 +2,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { Request, Response } from 'express'; -vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') })); +vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') })); +vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); vi.mock('../../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) })); import { AuthPublicController } from '../../../src/nest/auth/auth-public.controller'; import { AuthController } from '../../../src/nest/auth/auth.controller'; import { RateLimitService } from '../../../src/nest/auth/rate-limit.service'; import type { AuthService } from '../../../src/nest/auth/auth.service'; -import { writeAudit } from '../../../src/services/auditLog'; +import type { AuditService } from '../../../src/nest/audit/audit.service'; import { isDemoEmail } from '../../../src/services/demo'; import type { User } from '../../../src/types'; @@ -22,6 +23,13 @@ function asvc(o: Partial = {}): AuthService { } function rl(): RateLimitService { return new RateLimitService(); } +// AuditService is constructor-injected since the auditLog DI migration; the +// wrappers keep the historical construction sites positional. +const writeAudit = vi.fn(); +const audit = { writeAudit } as unknown as AuditService; +const apc = (a: AuthService, limiter: RateLimitService) => new AuthPublicController(a, limiter, audit); +const ac = (a: AuthService, limiter: RateLimitService) => new AuthController(a, limiter, audit); + function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -67,17 +75,17 @@ describe('RateLimitService', () => { describe('AuthPublicController', () => { it('demo-login maps error, else sets the cookie + returns token/user', () => { - expect(thrown(() => new AuthPublicController(asvc({ demoLogin: vi.fn().mockReturnValue({ error: 'Demo disabled', status: 403 }) } as Partial), rl()).demoLogin(req, res))).toEqual({ status: 403, body: { error: 'Demo disabled' } }); + expect(thrown(() => apc(asvc({ demoLogin: vi.fn().mockReturnValue({ error: 'Demo disabled', status: 403 }) } as Partial), rl()).demoLogin(req, res))).toEqual({ status: 403, body: { error: 'Demo disabled' } }); const setAuthCookie = vi.fn(); - const c = new AuthPublicController(asvc({ demoLogin: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial), rl()); + const c = apc(asvc({ demoLogin: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial), rl()); expect(c.demoLogin(req, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req); }); it('register audits + sets cookie; maps error', () => { - expect(thrown(() => new AuthPublicController(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial), rl()).register({}, req, res))).toEqual({ status: 409, body: { error: 'Email taken' } }); + expect(thrown(() => apc(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial), rl()).register({}, req, res))).toEqual({ status: 409, body: { error: 'Email taken' } }); const setAuthCookie = vi.fn(); - const c = new AuthPublicController(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial), rl()); + const c = apc(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial), rl()); expect(c.register({ email: 'a@b.c', password: 'p' }, req, res)).toEqual({ token: 'tk', user }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.register' })); expect(setAuthCookie).toHaveBeenCalled(); @@ -86,39 +94,39 @@ describe('AuthPublicController', () => { it('invite 429 when rate-limited', () => { const s = rl(); s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, Date.now()); // not exhausted yet - const c = new AuthPublicController(asvc({ validateInviteToken: vi.fn().mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null }) } as Partial), s); + const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null }) } as Partial), s); expect(c.invite('tok', req)).toEqual({ valid: true, max_uses: 1, used_count: 0, expires_at: null }); }); it('login: mfa branch, success cookie, error mapping', async () => { const setAuthCookie = vi.fn(); - const mfa = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial), rl()); + const mfa = apc(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial), rl()); expect(await mfa.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' }); - const ok = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user, remember: true }), setAuthCookie } as Partial), rl()); + const ok = apc(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user, remember: true }), setAuthCookie } as Partial), rl()); expect(await ok.login({}, req, res)).toEqual({ token: 'tk', user }); // The "remember me" flag from the service rides through to the cookie service. expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req, true); - const bad = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial), rl()); + const bad = apc(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial), rl()); expect(await thrownAsync(() => bad.login({}, req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } }); }, 10000); it('forgot-password issues a reset email then returns the generic ok', async () => { const sendPasswordResetEmail = vi.fn().mockResolvedValue({ delivered: true }); - const c = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail } as Partial), rl()); + const c = apc(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail } as Partial), rl()); expect(await c.forgotPassword({ email: 'a@b.c' }, req)).toEqual({ ok: true }); expect(sendPasswordResetEmail).toHaveBeenCalledWith('a@b.c', 'https://x/reset-password?token=rt', 1); }, 10000); it('reset-password: error audits a fail, mfa branch, success', () => { - expect(thrown(() => new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial), rl()).resetPassword({}, req))).toEqual({ status: 400, body: { error: 'Invalid token' } }); + expect(thrown(() => apc(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial), rl()).resetPassword({}, req))).toEqual({ status: 400, body: { error: 'Invalid token' } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_fail' })); - expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial), rl()).resetPassword({}, req)).toEqual({ mfa_required: true }); - expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial), rl()).resetPassword({}, req)).toEqual({ success: true }); + expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial), rl()).resetPassword({}, req)).toEqual({ mfa_required: true }); + expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial), rl()).resetPassword({}, req)).toEqual({ success: true }); }); it('app-config forwards the optional user (present and absent)', () => { const getAppConfig = vi.fn().mockReturnValue({ version: '3' }); - const c = new AuthPublicController(asvc({ getAppConfig } as Partial), rl()); + const c = apc(asvc({ getAppConfig } as Partial), rl()); expect(c.appConfig({ user } as unknown as Request)).toEqual({ version: '3' }); expect(getAppConfig).toHaveBeenLastCalledWith(user); expect(c.appConfig({} as Request)).toEqual({ version: '3' }); @@ -126,13 +134,13 @@ describe('AuthPublicController', () => { }); it('invite maps a service error', () => { - const c = new AuthPublicController(asvc({ validateInviteToken: vi.fn().mockReturnValue({ error: 'Expired', status: 410 }) } as Partial), rl()); + const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ error: 'Expired', status: 410 }) } as Partial), rl()); expect(thrown(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } }); }); it('login takes the mfa-required branch and never sets a cookie', async () => { const setAuthCookie = vi.fn(); - const c = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt', auditAction: 'user.login_mfa' }), setAuthCookie } as Partial), rl()); + const c = apc(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt', auditAction: 'user.login_mfa' }), setAuthCookie } as Partial), rl()); expect(await c.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' }); expect(setAuthCookie).not.toHaveBeenCalled(); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_mfa' })); @@ -141,20 +149,20 @@ describe('AuthPublicController', () => { it('forgot-password: non-issued reason and a delivery failure both still return ok', async () => { // Non-issued (unknown email / throttled): audits the reason, no email sent. const sendNever = vi.fn(); - const skip = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'not_found', userId: null }), sendPasswordResetEmail: sendNever } as Partial), rl()); + const skip = apc(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'not_found', userId: null }), sendPasswordResetEmail: sendNever } as Partial), rl()); expect(await skip.forgotPassword({ email: 'x@y.z' }, req)).toEqual({ ok: true }); expect(sendNever).not.toHaveBeenCalled(); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_request', details: { reason: 'not_found' } })); // Issued but the mailer throws: swallowed, audited as failed, still ok. const boom = vi.fn().mockRejectedValue(new Error('smtp')); - const fail = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail: boom } as Partial), rl()); + const fail = apc(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail: boom } as Partial), rl()); expect(await fail.forgotPassword({ email: 'a@b.c' }, req)).toEqual({ ok: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ details: { delivered: 'failed' } })); }, 10000); it('forgot-password ignores a non-string email body', async () => { const requestPasswordReset = vi.fn().mockReturnValue({ reason: 'not_found', userId: null }); - const c = new AuthPublicController(asvc({ requestPasswordReset } as Partial), rl()); + const c = apc(asvc({ requestPasswordReset } as Partial), rl()); expect(await c.forgotPassword({ email: 42 } as { email?: unknown }, req)).toEqual({ ok: true }); expect(requestPasswordReset).toHaveBeenCalledWith('', expect.any(String)); }, 10000); @@ -163,12 +171,12 @@ describe('AuthPublicController', () => { const s = rl(); const now = Date.now(); for (let i = 0; i < 5; i++) s.check('reset', '9.9.9.9', 5, 15 * 60 * 1000, now); - const c = new AuthPublicController(asvc({ resetPassword: vi.fn() } as Partial), s); + const c = apc(asvc({ resetPassword: vi.fn() } as Partial), s); expect(thrown(() => c.resetPassword({}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('mfa/verify-login maps a service error', () => { - const c = new AuthPublicController(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial), rl()); + const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial), rl()); expect(thrown(() => c.verifyMfaLogin({}, req, res))).toEqual({ status: 401, body: { error: 'Bad code' } }); }); @@ -176,64 +184,64 @@ describe('AuthPublicController', () => { const s = rl(); const now = Date.now(); for (let i = 0; i < 10; i++) s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now); - const c = new AuthPublicController(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial), s); + const c = apc(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial), s); expect(thrown(() => c.register({}, req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); expect(thrown(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('mfa/verify-login sets cookie + audits; logout clears cookie', () => { const setAuthCookie = vi.fn(); - const c = new AuthPublicController(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial), rl()); + const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial), rl()); expect(c.verifyMfaLogin({}, req, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalled(); const clearAuthCookie = vi.fn(); - expect(new AuthPublicController(asvc({ clearAuthCookie } as Partial), rl()).logout(req, res)).toEqual({ success: true }); + expect(apc(asvc({ clearAuthCookie } as Partial), rl()).logout(req, res)).toEqual({ success: true }); expect(clearAuthCookie).toHaveBeenCalledWith(res, req); }); }); describe('AuthController (authenticated)', () => { it('GET /me 404 when missing, else returns the loaded user', () => { - expect(thrown(() => new AuthController(asvc({ getCurrentUser: vi.fn().mockReturnValue(undefined) } as Partial), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } }); - expect(new AuthController(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial), rl()).me(user)).toEqual({ user: { id: 1 } }); + expect(thrown(() => ac(asvc({ getCurrentUser: vi.fn().mockReturnValue(undefined) } as Partial), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } }); + expect(ac(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial), rl()).me(user)).toEqual({ user: { id: 1 } }); }); it('change-password maps error, else audits', () => { - expect(thrown(() => new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial), rl()).changePassword(user, {}, req))).toEqual({ status: 400, body: { error: 'Wrong' } }); - expect(new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial), rl()).changePassword(user, {}, req)).toEqual({ success: true }); + expect(thrown(() => ac(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial), rl()).changePassword(user, {}, req))).toEqual({ status: 400, body: { error: 'Wrong' } }); + expect(ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial), rl()).changePassword(user, {}, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_change' })); }); it('avatar 403 in demo mode, 400 without a file, else saves', async () => { process.env.DEMO_MODE = 'true'; vi.mocked(isDemoEmail).mockReturnValue(true); - expect(await thrownAsync(() => new AuthController(asvc(), rl()).avatar(user, { filename: 'a.jpg' } as Express.Multer.File))).toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } }); + expect(await thrownAsync(() => ac(asvc(), rl()).avatar(user, { filename: 'a.jpg' } as Express.Multer.File))).toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } }); vi.mocked(isDemoEmail).mockReturnValue(false); delete process.env.DEMO_MODE; - expect(await thrownAsync(() => new AuthController(asvc(), rl()).avatar(user, undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } }); + expect(await thrownAsync(() => ac(asvc(), rl()).avatar(user, undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } }); const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/a.jpg' }); - expect(await new AuthController(asvc({ saveAvatar } as Partial), rl()).avatar(user, { filename: 'a.jpg' } as Express.Multer.File)).toEqual({ avatar: '/a.jpg' }); + expect(await ac(asvc({ saveAvatar } as Partial), rl()).avatar(user, { filename: 'a.jpg' } as Express.Multer.File)).toEqual({ avatar: '/a.jpg' }); }); it('mfa/setup awaits the QR promise, maps a generation failure to 500', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); - const ok = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.resolve('') }) } as Partial), rl()); + const ok = ac(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.resolve('') }) } as Partial), rl()); expect(await ok.mfaSetup(user)).toEqual({ secret: 's', otpauth_url: 'o', qr_svg: '' }); - const fail = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.reject(new Error('x')) }) } as Partial), rl()); + const fail = ac(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.reject(new Error('x')) }) } as Partial), rl()); expect(await thrownAsync(() => fail.mfaSetup(user))).toEqual({ status: 500, body: { error: 'Could not generate QR code' } }); }); it('mfa/enable audits + returns backup codes; mcp-tokens create 201', () => { - const enable = new AuthController(asvc({ enableMfa: vi.fn().mockReturnValue({ mfa_enabled: true, backup_codes: ['a', 'b'] }) } as Partial), rl()); + const enable = ac(asvc({ enableMfa: vi.fn().mockReturnValue({ mfa_enabled: true, backup_codes: ['a', 'b'] }) } as Partial), rl()); expect(enable.mfaEnable(user, { code: '123456' }, req)).toEqual({ success: true, mfa_enabled: true, backup_codes: ['a', 'b'] }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_enable' })); - const tok = new AuthController(asvc({ createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) } as Partial), rl()); + const tok = ac(asvc({ createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) } as Partial), rl()); expect(tok.createMcpToken(user, { name: 'CLI' }, req)).toEqual({ token: 'mcp_x' }); }); it('resource-token 503 when unavailable, else returns the token payload', () => { - expect(thrown(() => new AuthController(asvc({ createResourceToken: vi.fn().mockReturnValue(null) } as Partial), rl()).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } }); - expect(new AuthController(asvc({ createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) } as Partial), rl()).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' }); + expect(thrown(() => ac(asvc({ createResourceToken: vi.fn().mockReturnValue(null) } as Partial), rl()).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } }); + expect(ac(asvc({ createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) } as Partial), rl()).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' }); }); it('rate-limited account ops throw 429 once the bucket is exhausted', () => { @@ -241,91 +249,91 @@ describe('AuthController (authenticated)', () => { const now = Date.now(); // exhaust the shared 'login' bucket for this ip (max 5) for (let i = 0; i < 5; i++) s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now); - const c = new AuthController(asvc({ changePassword: vi.fn() } as Partial), s); + const c = ac(asvc({ changePassword: vi.fn() } as Partial), s); expect(thrown(() => c.changePassword(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('change-password refreshes this device cookie when the service returns a token', () => { const setAuthCookie = vi.fn(); - const c = new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial), rl()); + const c = ac(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial), rl()); expect(c.changePassword(user, {}, req, res)).toEqual({ success: true }); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req); }); it('delete-account maps error, else audits and succeeds', () => { - expect(thrown(() => new AuthController(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } }); - expect(new AuthController(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial), rl()).deleteAccount(user, req)).toEqual({ success: true }); + expect(thrown(() => ac(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } }); + expect(ac(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial), rl()).deleteAccount(user, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.account_delete' })); }); it('maps-key + api-keys pass straight through to the service', () => { const updateMapsKey = vi.fn().mockReturnValue({ success: true }); - expect(new AuthController(asvc({ updateMapsKey } as Partial), rl()).mapsKey(user, { maps_api_key: 'k' })).toEqual({ success: true }); + expect(ac(asvc({ updateMapsKey } as Partial), rl()).mapsKey(user, { maps_api_key: 'k' })).toEqual({ success: true }); expect(updateMapsKey).toHaveBeenCalledWith(1, 'k'); const updateApiKeys = vi.fn().mockReturnValue({ ok: 1 }); - expect(new AuthController(asvc({ updateApiKeys } as Partial), rl()).apiKeys(user, { a: 1 })).toEqual({ ok: 1 }); + expect(ac(asvc({ updateApiKeys } as Partial), rl()).apiKeys(user, { a: 1 })).toEqual({ ok: 1 }); }); it('update-settings + get-settings map errors, else return their payloads', () => { - expect(thrown(() => new AuthController(asvc({ updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) } as Partial), rl()).updateSettings(user, {}))).toEqual({ status: 400, body: { error: 'Bad' } }); - expect(new AuthController(asvc({ updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) } as Partial), rl()).updateSettings(user, {})).toEqual({ success: true, user: { id: 1 } }); - expect(thrown(() => new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) } as Partial), rl()).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } }); - expect(new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) } as Partial), rl()).getSettings(user)).toEqual({ settings: { theme: 'dark' } }); + expect(thrown(() => ac(asvc({ updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) } as Partial), rl()).updateSettings(user, {}))).toEqual({ status: 400, body: { error: 'Bad' } }); + expect(ac(asvc({ updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) } as Partial), rl()).updateSettings(user, {})).toEqual({ success: true, user: { id: 1 } }); + expect(thrown(() => ac(asvc({ getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) } as Partial), rl()).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } }); + expect(ac(asvc({ getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) } as Partial), rl()).getSettings(user)).toEqual({ settings: { theme: 'dark' } }); }); it('delete-avatar + users + travel-stats delegate to the service', async () => { const deleteAvatar = vi.fn().mockResolvedValue({ removed: true }); - expect(await new AuthController(asvc({ deleteAvatar } as Partial), rl()).deleteAvatar(user)).toEqual({ removed: true }); + expect(await ac(asvc({ deleteAvatar } as Partial), rl()).deleteAvatar(user)).toEqual({ removed: true }); const listUsers = vi.fn().mockReturnValue([{ id: 1 }]); - expect(new AuthController(asvc({ listUsers } as Partial), rl()).users(user)).toEqual({ users: [{ id: 1 }] }); + expect(ac(asvc({ listUsers } as Partial), rl()).users(user)).toEqual({ users: [{ id: 1 }] }); expect(listUsers).toHaveBeenCalledWith(1); const getTravelStats = vi.fn().mockReturnValue({ countries: 3 }); - expect(new AuthController(asvc({ getTravelStats } as Partial), rl()).travelStats(user)).toEqual({ countries: 3 }); + expect(ac(asvc({ getTravelStats } as Partial), rl()).travelStats(user)).toEqual({ countries: 3 }); }); it('validate-keys maps error, else returns the maps/weather payload', async () => { - expect(await thrownAsync(() => new AuthController(asvc({ validateKeys: vi.fn().mockResolvedValue({ error: 'fail', status: 502 }) } as Partial), rl()).validateKeys(user))).toEqual({ status: 502, body: { error: 'fail' } }); - const ok = new AuthController(asvc({ validateKeys: vi.fn().mockResolvedValue({ maps: true, weather: false, maps_details: { ok: 1 } }) } as Partial), rl()); + expect(await thrownAsync(() => ac(asvc({ validateKeys: vi.fn().mockResolvedValue({ error: 'fail', status: 502 }) } as Partial), rl()).validateKeys(user))).toEqual({ status: 502, body: { error: 'fail' } }); + const ok = ac(asvc({ validateKeys: vi.fn().mockResolvedValue({ maps: true, weather: false, maps_details: { ok: 1 } }) } as Partial), rl()); expect(await ok.validateKeys(user)).toEqual({ maps: true, weather: false, maps_details: { ok: 1 } }); }); it('app-settings get maps error, else returns data; put maps error, else audits', () => { - expect(thrown(() => new AuthController(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } }); - expect(new AuthController(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial), rl()).getAppSettings(user)).toEqual({ x: 1 }); - expect(thrown(() => new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } }); - expect(new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true }); + expect(thrown(() => ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } }); + expect(ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial), rl()).getAppSettings(user)).toEqual({ x: 1 }); + expect(thrown(() => ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } }); + expect(ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' })); }); it('mfa/setup maps a service error before ever awaiting the QR promise', async () => { - const c = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ error: 'already on', status: 409 }) } as Partial), rl()); + const c = ac(asvc({ setupMfa: vi.fn().mockReturnValue({ error: 'already on', status: 409 }) } as Partial), rl()); expect(await thrownAsync(() => c.mfaSetup(user))).toEqual({ status: 409, body: { error: 'already on' } }); }); it('mfa/enable + mfa/disable map errors', () => { - expect(thrown(() => new AuthController(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } }); - expect(thrown(() => new AuthController(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial), rl()).mfaDisable(user, {}, req))).toEqual({ status: 401, body: { error: 'Wrong' } }); - const ok = new AuthController(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial), rl()); + expect(thrown(() => ac(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } }); + expect(thrown(() => ac(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial), rl()).mfaDisable(user, {}, req))).toEqual({ status: 401, body: { error: 'Wrong' } }); + const ok = ac(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial), rl()); expect(ok.mfaDisable(user, {}, req)).toEqual({ success: true, mfa_enabled: false }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' })); }); it('mcp-tokens list + create error + delete error/success', () => { - expect(new AuthController(asvc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) } as Partial), rl()).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] }); - expect(thrown(() => new AuthController(asvc({ createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) } as Partial), rl()).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } }); - expect(thrown(() => new AuthController(asvc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) } as Partial), rl()).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } }); - expect(new AuthController(asvc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial), rl()).deleteMcpToken(user, 'tid')).toEqual({ success: true }); + expect(ac(asvc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) } as Partial), rl()).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] }); + expect(thrown(() => ac(asvc({ createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) } as Partial), rl()).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } }); + expect(thrown(() => ac(asvc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) } as Partial), rl()).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } }); + expect(ac(asvc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial), rl()).deleteMcpToken(user, 'tid')).toEqual({ success: true }); }); it('ws-token maps error, else returns the token', () => { - expect(thrown(() => new AuthController(asvc({ createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) } as Partial), rl()).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } }); - expect(new AuthController(asvc({ createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) } as Partial), rl()).wsToken(user)).toEqual({ token: 'ws' }); + expect(thrown(() => ac(asvc({ createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) } as Partial), rl()).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } }); + expect(ac(asvc({ createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) } as Partial), rl()).wsToken(user)).toEqual({ token: 'ws' }); }); it('avatar saves when not in demo mode (env present but email is not a demo email)', async () => { process.env.DEMO_MODE = 'true'; vi.mocked(isDemoEmail).mockReturnValue(false); const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/b.png' }); - expect(await new AuthController(asvc({ saveAvatar } as Partial), rl()).avatar(user, { filename: 'b.png' } as Express.Multer.File)).toEqual({ avatar: '/b.png' }); + expect(await ac(asvc({ saveAvatar } as Partial), rl()).avatar(user, { filename: 'b.png' } as Express.Multer.File)).toEqual({ avatar: '/b.png' }); }); }); diff --git a/server/tests/unit/nest/backup.controller.test.ts b/server/tests/unit/nest/backup.controller.test.ts index 2e6c4746b..df6eba65e 100644 --- a/server/tests/unit/nest/backup.controller.test.ts +++ b/server/tests/unit/nest/backup.controller.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { Request, Response } from 'express'; -vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') })); +vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') })); +vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); // The controller imports the tmp-dir + size cap at module load. The thin // BackupService wrapper forwards every call straight into this module, so the // mock also stubs the delegated functions for the wrapper tests below. @@ -26,13 +27,19 @@ import { BackupController } from '../../../src/nest/backup/backup.controller'; import { BackupService as RealBackupService } from '../../../src/nest/backup/backup.service'; import { AdminGuard } from '../../../src/nest/auth/admin.guard'; import type { BackupService } from '../../../src/nest/backup/backup.service'; -import { writeAudit } from '../../../src/services/auditLog'; +import type { AuditService } from '../../../src/nest/audit/audit.service'; import * as backupSvc from '../../../src/services/backupService'; import type { User } from '../../../src/types'; const user = { id: 1, role: 'admin', email: 'a@example.test' } as User; const req = { ip: '1.2.3.4', headers: {} } as Request; +// AuditService is constructor-injected since the auditLog DI migration; the +// wrapper keeps the historical construction sites positional. +const writeAudit = vi.fn(); +const audit = { writeAudit } as unknown as AuditService; +const bc = (s: BackupService) => new BackupController(s, audit); + function svc(o: Partial = {}): BackupService { return { listBackups: vi.fn().mockReturnValue([]), @@ -82,74 +89,74 @@ describe('AdminGuard (used by BackupController)', () => { describe('BackupController', () => { it('GET /list returns backups, 500 on error', () => { - expect(new BackupController(svc({ listBackups: vi.fn().mockReturnValue([{ filename: 'a.zip' }]) } as Partial)).list()).toEqual({ backups: [{ filename: 'a.zip' }] }); - expect(thrown(() => new BackupController(svc({ listBackups: vi.fn(() => { throw new Error('io'); }) } as Partial)).list())).toEqual({ status: 500, body: { error: 'Error loading backups' } }); + expect(bc(svc({ listBackups: vi.fn().mockReturnValue([{ filename: 'a.zip' }]) } as Partial)).list()).toEqual({ backups: [{ filename: 'a.zip' }] }); + expect(thrown(() => bc(svc({ listBackups: vi.fn(() => { throw new Error('io'); }) } as Partial)).list())).toEqual({ status: 500, body: { error: 'Error loading backups' } }); }); it('POST /create 429 when rate-limited, else creates + audits', async () => { - expect(await thrownAsync(() => new BackupController(svc({ checkRateLimit: vi.fn().mockReturnValue(false) })).create(user, req))).toEqual({ status: 429, body: { error: 'Too many backup requests. Please try again later.' } }); + expect(await thrownAsync(() => bc(svc({ checkRateLimit: vi.fn().mockReturnValue(false) })).create(user, req))).toEqual({ status: 429, body: { error: 'Too many backup requests. Please try again later.' } }); const createBackup = vi.fn().mockResolvedValue({ filename: 'b.zip', size: 10 }); - const res = await new BackupController(svc({ createBackup } as Partial)).create(user, req); + const res = await bc(svc({ createBackup } as Partial)).create(user, req); expect(res).toEqual({ success: true, backup: { filename: 'b.zip', size: 10 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.create', resource: 'b.zip' })); }); it('GET /download 400 invalid / 404 missing, else res.download', () => { const res = { download: vi.fn() } as unknown as Response; - expect(thrown(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).download('x', res))).toEqual({ status: 400, body: { error: 'Invalid filename' } }); - expect(thrown(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).download('x.zip', res))).toEqual({ status: 404, body: { error: 'Backup not found' } }); - new BackupController(svc()).download('x.zip', res); + expect(thrown(() => bc(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).download('x', res))).toEqual({ status: 400, body: { error: 'Invalid filename' } }); + expect(thrown(() => bc(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).download('x.zip', res))).toEqual({ status: 404, body: { error: 'Backup not found' } }); + bc(svc()).download('x.zip', res); expect(res.download).toHaveBeenCalledWith('/b/x.zip', 'x.zip'); }); it('POST /restore maps the service status, else audits', async () => { - expect(await thrownAsync(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).restore(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } }); - expect(await thrownAsync(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).restore(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } }); - expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad zip' }) } as Partial)).restore(user, 'x.zip', req))).toEqual({ status: 422, body: { error: 'bad zip' } }); - const res = await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial)).restore(user, 'x.zip', req); + expect(await thrownAsync(() => bc(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).restore(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } }); + expect(await thrownAsync(() => bc(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).restore(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } }); + expect(await thrownAsync(() => bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad zip' }) } as Partial)).restore(user, 'x.zip', req))).toEqual({ status: 422, body: { error: 'bad zip' } }); + const res = await bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial)).restore(user, 'x.zip', req); expect(res).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.restore', resource: 'x.zip' })); }); it('POST /restore falls back to status 400 when the service omits one', async () => { - expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, error: 'nope' }) } as Partial)).restore(user, 'x.zip', req))).toEqual({ status: 400, body: { error: 'nope' } }); + expect(await thrownAsync(() => bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, error: 'nope' }) } as Partial)).restore(user, 'x.zip', req))).toEqual({ status: 400, body: { error: 'nope' } }); }); it('POST /upload-restore 400 without a file, cleans up the tmp file', async () => { - expect(await thrownAsync(() => new BackupController(svc()).uploadRestore(user, undefined, req))).toEqual({ status: 400, body: { error: 'No file uploaded' } }); + expect(await thrownAsync(() => bc(svc()).uploadRestore(user, undefined, req))).toEqual({ status: 400, body: { error: 'No file uploaded' } }); }); it('POST /upload-restore success audits + reports', async () => { const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: 'up.zip' } as Express.Multer.File; - const res = await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial)).uploadRestore(user, file, req); + const res = await bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial)).uploadRestore(user, file, req); expect(res).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.upload_restore', resource: 'up.zip' })); }); it('POST /upload-restore maps a failed restore status', async () => { const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: 'up.zip' } as Express.Multer.File; - expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad' }) } as Partial)).uploadRestore(user, file, req))).toEqual({ status: 422, body: { error: 'bad' } }); + expect(await thrownAsync(() => bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad' }) } as Partial)).uploadRestore(user, file, req))).toEqual({ status: 422, body: { error: 'bad' } }); }); it('POST /upload-restore falls back to a default name and maps unexpected errors to 500', async () => { const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File; - expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial)).uploadRestore(user, file, req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } }); + expect(await thrownAsync(() => bc(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial)).uploadRestore(user, file, req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } }); const ok = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File; - await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial)).uploadRestore(user, ok, req); + await bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial)).uploadRestore(user, ok, req); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.upload_restore', resource: 'upload.zip' })); }); it('maps unexpected service errors to 500 (create, restore, auto-settings)', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); - expect(await thrownAsync(() => new BackupController(svc({ createBackup: vi.fn().mockRejectedValue(new Error('disk')) } as Partial)).create(user, req))).toEqual({ status: 500, body: { error: 'Error creating backup' } }); - expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial)).restore(user, 'x.zip', req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } }); - expect(thrown(() => new BackupController(svc({ getAutoSettings: vi.fn(() => { throw new Error('io'); }) } as Partial)).autoSettings())).toEqual({ status: 500, body: { error: 'Could not load backup settings' } }); + expect(await thrownAsync(() => bc(svc({ createBackup: vi.fn().mockRejectedValue(new Error('disk')) } as Partial)).create(user, req))).toEqual({ status: 500, body: { error: 'Error creating backup' } }); + expect(await thrownAsync(() => bc(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial)).restore(user, 'x.zip', req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } }); + expect(thrown(() => bc(svc({ getAutoSettings: vi.fn(() => { throw new Error('io'); }) } as Partial)).autoSettings())).toEqual({ status: 500, body: { error: 'Could not load backup settings' } }); }); it('PUT /auto-settings maps errors to 500 (with a dev-only detail)', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); process.env.NODE_ENV = 'development'; - const r = thrown(() => new BackupController(svc({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) } as Partial)).updateAutoSettings(user, {}, req)); + const r = thrown(() => bc(svc({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) } as Partial)).updateAutoSettings(user, {}, req)); expect(r.status).toBe(500); expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: 'parse fail' }); }); @@ -157,29 +164,29 @@ describe('BackupController', () => { it('PUT /auto-settings hides the detail in production and stringifies non-Error throws', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); process.env.NODE_ENV = 'production'; - const r = thrown(() => new BackupController(svc({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) } as Partial)).updateAutoSettings(user, {}, req)); + const r = thrown(() => bc(svc({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) } as Partial)).updateAutoSettings(user, {}, req)); expect(r.status).toBe(500); expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: undefined }); }); it('PUT /auto-settings tolerates a missing body', () => { const updateAutoSettings = vi.fn().mockReturnValue({ enabled: false, interval: 'weekly', keep_days: 30 }); - new BackupController(svc({ updateAutoSettings } as Partial)).updateAutoSettings(user, undefined as unknown as Record, req); + bc(svc({ updateAutoSettings } as Partial)).updateAutoSettings(user, undefined as unknown as Record, req); expect(updateAutoSettings).toHaveBeenCalledWith({}); }); it('GET/PUT /auto-settings', () => { - expect(new BackupController(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' }); - const res = new BackupController(svc({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) } as Partial)).updateAutoSettings(user, { enabled: true }, req); + expect(bc(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' }); + const res = bc(svc({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) } as Partial)).updateAutoSettings(user, { enabled: true }, req); expect(res).toEqual({ settings: { enabled: true, interval: 'daily', keep_days: 7 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.auto_settings' })); }); it('DELETE /:filename 400/404, else deletes + audits', () => { - expect(thrown(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).remove(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } }); - expect(thrown(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).remove(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } }); + expect(thrown(() => bc(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).remove(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } }); + expect(thrown(() => bc(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).remove(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } }); const deleteBackup = vi.fn(); - expect(new BackupController(svc({ deleteBackup } as Partial)).remove(user, 'x.zip', req)).toEqual({ success: true }); + expect(bc(svc({ deleteBackup } as Partial)).remove(user, 'x.zip', req)).toEqual({ success: true }); expect(deleteBackup).toHaveBeenCalledWith('x.zip'); }); }); diff --git a/server/tests/unit/nest/memories.controller.test.ts b/server/tests/unit/nest/memories.controller.test.ts index 362a6f734..4add893d4 100644 --- a/server/tests/unit/nest/memories.controller.test.ts +++ b/server/tests/unit/nest/memories.controller.test.ts @@ -8,7 +8,7 @@ import type { MemoriesService } from '../../../src/nest/memories/memories.servic import type { User } from '../../../src/types'; const { getClientIp } = vi.hoisted(() => ({ getClientIp: vi.fn(() => '1.2.3.4') })); -vi.mock('../../../src/services/auditLog', () => ({ getClientIp })); +vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp })); const user = { id: 7, role: 'user', email: 'u@example.test' } as User; diff --git a/server/tests/unit/nest/oauth.controller.test.ts b/server/tests/unit/nest/oauth.controller.test.ts index 3b6be649d..dd2beec30 100644 --- a/server/tests/unit/nest/oauth.controller.test.ts +++ b/server/tests/unit/nest/oauth.controller.test.ts @@ -2,9 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { Request, Response } from 'express'; -vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logWarn: vi.fn() })); +vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') })); +vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); -import { getClientIp } from '../../../src/services/auditLog'; +import { getClientIp } from '../../../src/nest/audit/client-ip'; const getClientIpMock = vi.mocked(getClientIp); import { OauthPublicController } from '../../../src/nest/oauth/oauth-public.controller'; @@ -17,6 +18,12 @@ function osvc(o: Partial = {}): OauthService { return { mcpEnabled: vi.fn().mockReturnValue(true), mcpSafeUrl: vi.fn().mockReturnValue('https://app'), ...o } as unknown as OauthService; } function rl(): RateLimitService { return new RateLimitService(); } + +// AuditService is constructor-injected since the auditLog DI migration; the +// wrapper keeps the historical construction sites positional. +const writeAudit = vi.fn(); +const audit = { writeAudit } as unknown as AuditService; +const opc = (s: OauthService, limiter: RateLimitService) => new OauthPublicController(s, limiter, audit); function makeRes() { const res = { statusCode: 200, headers: {} as Record, body: undefined as unknown, ended: false, @@ -44,14 +51,14 @@ describe('OauthPublicController /token', () => { it('404 (empty) when MCP is disabled', () => { const res = makeRes(); - new OauthPublicController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).token(reqWith({}), res); + opc(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).token(reqWith({}), res); expect(res.statusCode).toBe(404); expect(res.ended).toBe(true); }); it('sets no-store headers + 401 without client_id', () => { const res = makeRes(); - new OauthPublicController(osvc(), rl()).token(reqWith({}), res); + opc(osvc(), rl()).token(reqWith({}), res); expect(res.headers['Cache-Control']).toBe('no-store'); expect(res.statusCode).toBe(401); expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' }); @@ -59,7 +66,7 @@ describe('OauthPublicController /token', () => { it('authorization_code: invalid_grant on a bad code, success issues tokens', () => { const bad = makeRes(); - new OauthPublicController(osvc({ consumeAuthCode: vi.fn().mockReturnValue(null) }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), bad); + opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue(null) }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), bad); expect(bad.statusCode).toBe(400); expect(bad.body).toEqual({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); @@ -70,7 +77,7 @@ describe('OauthPublicController /token', () => { verifyPKCE: vi.fn().mockReturnValue(true), issueTokens: vi.fn().mockReturnValue({ access_token: 'at', token_type: 'Bearer' }), }); - new OauthPublicController(svc, rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), ok); + opc(svc, rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), ok); expect(ok.body).toEqual({ access_token: 'at', token_type: 'Bearer' }); }); @@ -78,7 +85,7 @@ describe('OauthPublicController /token', () => { const base = { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }; const mk = (pending: Record, extra: Partial = {}, body = base) => { const res = makeRes(); - new OauthPublicController(osvc({ consumeAuthCode: vi.fn().mockReturnValue(pending), authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), verifyPKCE: vi.fn().mockReturnValue(true), ...extra }), rl()).token(reqWith(body), res); + opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue(pending), authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), verifyPKCE: vi.fn().mockReturnValue(true), ...extra }), rl()).token(reqWith(body), res); return res; }; expect(mk({ clientId: 'OTHER', redirectUri: 'u', userId: 1 }).statusCode).toBe(400); // client_id mismatch @@ -90,41 +97,41 @@ describe('OauthPublicController /token', () => { it('authorization_code: 400 when code/redirect/verifier missing', () => { const res = makeRes(); - new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c' }), res); + opc(osvc(), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c' }), res); expect(res.statusCode).toBe(400); expect(res.body).toEqual({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' }); }); it('refresh_token: 400 without a refresh_token, maps a service error, success', () => { const miss = makeRes(); - new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c' }), miss); + opc(osvc(), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c' }), miss); expect(miss.statusCode).toBe(400); const err = makeRes(); - new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant', status: 400 }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), err); + opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant', status: 400 }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), err); expect(err.body).toEqual({ error: 'invalid_grant', error_description: 'Refresh token is invalid or expired' }); const ok = makeRes(); - new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ tokens: { access_token: 'new' } }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), ok); + opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ tokens: { access_token: 'new' } }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), ok); expect(ok.body).toEqual({ access_token: 'new' }); }); it('client_credentials: 401 without secret, invalid_scope for a disallowed scope', () => { const noSecret = makeRes(); - new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c' }), noSecret); + opc(osvc(), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c' }), noSecret); expect(noSecret.statusCode).toBe(401); const badScope = makeRes(); - new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a zzz' }), badScope); + opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a zzz' }), badScope); expect(badScope.statusCode).toBe(400); expect(badScope.body).toEqual({ error: 'invalid_scope', error_description: 'Scopes not allowed for this client: zzz' }); }); it('client_credentials: unauthorized_client for a public client, else issues a token', () => { const pub = makeRes(); - new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: true, user_id: null, allows_client_credentials: false, allowed_scopes: '[]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), pub); + opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: true, user_id: null, allows_client_credentials: false, allowed_scopes: '[]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), pub); expect(pub.statusCode).toBe(400); expect(pub.body).toEqual({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' }); const ok = makeRes(); - new OauthPublicController(osvc({ + opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' }), issueClientCredentialsToken: vi.fn().mockReturnValue({ access_token: 'cc_at' }), }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), ok); @@ -133,7 +140,7 @@ describe('OauthPublicController /token', () => { it('unsupported grant -> 400', () => { const res = makeRes(); - new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'password', client_id: 'c' }), res); + opc(osvc(), rl()).token(reqWith({ grant_type: 'password', client_id: 'c' }), res); expect(res.statusCode).toBe(400); expect(res.body).toEqual({ error: 'unsupported_grant_type', error_description: 'Unsupported grant_type: password' }); }); @@ -142,13 +149,13 @@ describe('OauthPublicController /token', () => { const s = rl(); for (let i = 0; i < 30; i++) s.check('oauth_token', '7.7.7.7|c', 30, 60000, Date.now()); const res = makeRes(); - new OauthPublicController(osvc(), s).token(reqWith({ client_id: 'c' }), res); + opc(osvc(), s).token(reqWith({ client_id: 'c' }), res); expect(res.statusCode).toBe(429); }); it('falls back to {} when the body is not an object', () => { const res = makeRes(); - new OauthPublicController(osvc(), rl()).token({ ip: '7.7.7.7', body: 'not-an-object' } as unknown as Request, res); + opc(osvc(), rl()).token({ ip: '7.7.7.7', body: 'not-an-object' } as unknown as Request, res); // no client_id in the {} fallback -> 401 expect(res.statusCode).toBe(401); expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' }); @@ -156,7 +163,7 @@ describe('OauthPublicController /token', () => { it('authorization_code: invalid client secret writes an audit + 401', () => { const res = makeRes(); - new OauthPublicController(osvc({ + opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }), authenticateClient: vi.fn().mockReturnValue(null), }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), res); @@ -166,7 +173,7 @@ describe('OauthPublicController /token', () => { it('refresh_token: invalid_client maps to its specific 401 message', () => { const res = makeRes(); - new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl()) + opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl()) .token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res); expect(res.statusCode).toBe(401); expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' }); @@ -174,14 +181,14 @@ describe('OauthPublicController /token', () => { it('refresh_token: defaults the status to 400 when the service omits it', () => { const res = makeRes(); - new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant' }) }), rl()) + opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant' }) }), rl()) .token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res); expect(res.statusCode).toBe(400); }); it('client_credentials: 401 when the client cannot be authenticated', () => { const res = makeRes(); - new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) + opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) .token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), res); expect(res.statusCode).toBe(401); expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' }); @@ -190,7 +197,7 @@ describe('OauthPublicController /token', () => { it('client_credentials: honours a valid requested scope subset', () => { const res = makeRes(); const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' }); - new OauthPublicController(osvc({ + opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' }), issueClientCredentialsToken, }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a' }), res); @@ -201,7 +208,7 @@ describe('OauthPublicController /token', () => { it('client_credentials: derives the audience from an explicit resource', () => { const res = makeRes(); const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' }); - new OauthPublicController(osvc({ + opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }), issueClientCredentialsToken, }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', resource: 'https://aud/' }), res); @@ -212,7 +219,7 @@ describe('OauthPublicController /token', () => { it('logs a dash for a missing ip on the authorization_code client-auth failure', () => { getClientIpMock.mockReturnValueOnce(undefined); const res = makeRes(); - new OauthPublicController(osvc({ + opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }), authenticateClient: vi.fn().mockReturnValue(null), }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), res); @@ -222,7 +229,7 @@ describe('OauthPublicController /token', () => { it('logs a dash for a missing ip on the refresh invalid_client failure', () => { getClientIpMock.mockReturnValueOnce(undefined); const res = makeRes(); - new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl()) + opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl()) .token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res); expect(res.statusCode).toBe(401); }); @@ -230,7 +237,7 @@ describe('OauthPublicController /token', () => { it('logs a dash for a missing ip on the client_credentials auth failure', () => { getClientIpMock.mockReturnValueOnce(undefined); const res = makeRes(); - new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) + opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) .token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), res); expect(res.statusCode).toBe(401); }); @@ -239,24 +246,24 @@ describe('OauthPublicController /token', () => { describe('OauthPublicController /userinfo + /revoke', () => { it('userinfo: 401 challenge without a Bearer, returns claims with a valid token', () => { const r1 = makeRes(); - new OauthPublicController(osvc(), rl()).userinfo(undefined, r1); + opc(osvc(), rl()).userinfo(undefined, r1); expect(r1.statusCode).toBe(401); expect(r1.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP"'); const r2 = makeRes(); - new OauthPublicController(osvc({ getUserByAccessToken: vi.fn().mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' } }) }), rl()).userinfo('Bearer tok', r2); + opc(osvc({ getUserByAccessToken: vi.fn().mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' } }) }), rl()).userinfo('Bearer tok', r2); expect(r2.body).toEqual({ sub: '1', email: 'a@b.c', email_verified: true, preferred_username: 'u' }); }); it('userinfo: 404 empty when MCP is disabled', () => { const res = makeRes(); - new OauthPublicController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).userinfo('Bearer tok', res); + opc(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).userinfo('Bearer tok', res); expect(res.statusCode).toBe(404); expect(res.ended).toBe(true); }); it('userinfo: 401 with the error challenge when the token is unknown', () => { const res = makeRes(); - new OauthPublicController(osvc({ getUserByAccessToken: vi.fn().mockReturnValue(null) }), rl()).userinfo('Bearer tok', res); + opc(osvc({ getUserByAccessToken: vi.fn().mockReturnValue(null) }), rl()).userinfo('Bearer tok', res); expect(res.statusCode).toBe(401); expect(res.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP", error="invalid_token"'); expect(res.body).toEqual({ error: 'invalid_token' }); @@ -264,11 +271,11 @@ describe('OauthPublicController /userinfo + /revoke', () => { it('revoke: 400 without token/client, always 200 once authenticated', () => { const r1 = makeRes(); - new OauthPublicController(osvc(), rl()).revoke({ ip: '1', body: { client_id: 'c' } } as Request, r1); + opc(osvc(), rl()).revoke({ ip: '1', body: { client_id: 'c' } } as Request, r1); expect(r1.statusCode).toBe(400); const r2 = makeRes(); const revokeToken = vi.fn(); - new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken }), rl()).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, r2); + opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken }), rl()).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, r2); expect(r2.statusCode).toBe(200); expect(r2.body).toEqual({}); expect(revokeToken).toHaveBeenCalled(); @@ -276,7 +283,7 @@ describe('OauthPublicController /userinfo + /revoke', () => { it('revoke: 404 empty when MCP is disabled', () => { const res = makeRes(); - new OauthPublicController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).revoke({ ip: '1', body: {} } as Request, res); + opc(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).revoke({ ip: '1', body: {} } as Request, res); expect(res.statusCode).toBe(404); expect(res.ended).toBe(true); }); @@ -285,13 +292,13 @@ describe('OauthPublicController /userinfo + /revoke', () => { const s = rl(); for (let i = 0; i < 10; i++) s.check('oauth_revoke', '1', 10, 60000, Date.now()); const res = makeRes(); - new OauthPublicController(osvc(), s).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res); + opc(osvc(), s).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res); expect(res.statusCode).toBe(429); }); it('revoke: falls back to a default ip key and {} body when both are missing', () => { const res = makeRes(); - new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken: vi.fn() }), rl()) + opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken: vi.fn() }), rl()) .revoke({ body: undefined } as unknown as Request, res); // body fell back to {} -> token/client missing -> 400 expect(res.statusCode).toBe(400); @@ -299,7 +306,7 @@ describe('OauthPublicController /userinfo + /revoke', () => { it('revoke: 401 when the client credentials are invalid', () => { const res = makeRes(); - new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) + opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) .revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res); expect(res.statusCode).toBe(401); expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' }); @@ -308,7 +315,7 @@ describe('OauthPublicController /userinfo + /revoke', () => { it('revoke: logs a dash for a missing ip on the invalid-client failure', () => { getClientIpMock.mockReturnValueOnce(undefined); const res = makeRes(); - new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) + opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) .revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res); expect(res.statusCode).toBe(401); }); diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index 27e115e80..5aaeb3ed3 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { Request } from 'express'; -vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn() })); +vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') })); +vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); const { isDemoEmail } = vi.hoisted(() => ({ isDemoEmail: vi.fn(() => false) })); vi.mock('../../../src/services/demo', () => ({ isDemoEmail })); // Mock the Unsplash cover internalisation so the controller test never hits the @@ -20,6 +21,12 @@ import type { User } from '../../../src/types'; const user = { id: 1, role: 'user', email: 'u@example.test' } as User; const req = { headers: {} } as Request; +// AuditService is constructor-injected since the auditLog DI migration; the +// wrapper keeps the historical construction sites positional. +const writeAudit = vi.fn(); +const audit = { writeAudit } as unknown as AuditService; +const tc = (s: TripsService) => new TripsController(s, audit); + function svc(o: Partial = {}): TripsService { return { canAccessTrip: vi.fn().mockReturnValue({ user_id: 1 }), @@ -53,13 +60,13 @@ beforeEach(() => vi.clearAllMocks()); describe('TripsController (parity with the legacy /api/trips route)', () => { it('GET / lists for the user with the archived flag', () => { const list = vi.fn().mockReturnValue([{ id: 1 }]); - expect(new TripsController(svc({ list } as Partial)).list(user, '1')).toEqual({ trips: [{ id: 1 }] }); + expect(tc(svc({ list } as Partial)).list(user, '1')).toEqual({ trips: [{ id: 1 }] }); expect(list).toHaveBeenCalledWith(1, 1); }); it('GET / defaults the archived flag to 0 when not "1"', () => { const list = vi.fn().mockReturnValue([]); - const c = new TripsController(svc({ list } as Partial)); + const c = tc(svc({ list } as Partial)); c.list(user, undefined); expect(list).toHaveBeenLastCalledWith(1, 0); c.list(user, '0'); @@ -68,72 +75,72 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { describe('POST / (create)', () => { it('403 without trip_create, 400 without title', () => { - expect(thrown(() => new TripsController(svc({ can: vi.fn().mockReturnValue(false) })).create(user, { title: 'T' }, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); - expect(thrown(() => new TripsController(svc()).create(user, {}, req))).toEqual({ status: 400, body: { error: 'Title is required' } }); + expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).create(user, { title: 'T' }, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); + expect(thrown(() => tc(svc()).create(user, {}, req))).toEqual({ status: 400, body: { error: 'Title is required' } }); }); it('infers end_date from start_date (+6 days) and creates', () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - new TripsController(svc({ create } as Partial)).create(user, { title: 'T', start_date: '2026-07-01' }, req); + tc(svc({ create } as Partial)).create(user, { title: 'T', start_date: '2026-07-01' }, req); expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ start_date: '2026-07-01', end_date: '2026-07-07' })); }); it('400 when end_date precedes start_date', () => { - expect(thrown(() => new TripsController(svc()).create(user, { title: 'T', start_date: '2026-07-10', end_date: '2026-07-01' }, req))).toEqual({ + expect(thrown(() => tc(svc()).create(user, { title: 'T', start_date: '2026-07-10', end_date: '2026-07-01' }, req))).toEqual({ status: 400, body: { error: 'End date must be after start date' }, }); }); it('infers start_date from end_date (-6 days) and parses day_count', () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - new TripsController(svc({ create } as Partial)).create(user, { title: 'T', end_date: '2026-07-07', day_count: '40' }, req); + tc(svc({ create } as Partial)).create(user, { title: 'T', end_date: '2026-07-07', day_count: '40' }, req); expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ start_date: '2026-07-01', end_date: '2026-07-07', day_count: 40 })); }); it('clamps a non-numeric day_count to the default of 7', () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - new TripsController(svc({ create } as Partial)).create(user, { title: 'T', day_count: 'abc' }, req); + tc(svc({ create } as Partial)).create(user, { title: 'T', day_count: 'abc' }, req); expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: 7 })); }); it('logs the reminder when reminderDays is set', () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 3 }); - expect(new TripsController(svc({ create } as Partial)).create(user, { title: 'T' }, req)).toEqual({ trip: { id: 9 } }); + expect(tc(svc({ create } as Partial)).create(user, { title: 'T' }, req)).toEqual({ trip: { id: 9 } }); }); }); it('GET /:id 404 when missing', () => { - expect(thrown(() => new TripsController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); it('GET /:id returns the trip when present', () => { const s = svc({ get: vi.fn().mockReturnValue({ id: 9 }) } as Partial); - expect(new TripsController(s).get(user, '9')).toEqual({ trip: { id: 9 } }); + expect(tc(s).get(user, '9')).toEqual({ trip: { id: 9 } }); }); describe('PUT /:id', () => { it('404 when no access; 403 on archive without trip_archive', async () => { - expect(await thrownAsync(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).update(user, '9', {}, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(await thrownAsync(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).update(user, '9', {}, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); const s = svc({ can: vi.fn().mockImplementation((a: string) => a !== 'trip_archive') }); - expect(await thrownAsync(() => new TripsController(s).update(user, '9', { is_archived: 1 }, req))).toEqual({ status: 403, body: { error: 'No permission to archive/unarchive this trip' } }); + expect(await thrownAsync(() => tc(s).update(user, '9', { is_archived: 1 }, req))).toEqual({ status: 403, body: { error: 'No permission to archive/unarchive this trip' } }); }); it('updates, audits a change and broadcasts', async () => { const update = vi.fn().mockReturnValue({ updatedTrip: { id: 9 }, changes: { title: { oldValue: 'a', newValue: 'b' } }, newTitle: 'b', newReminder: 0, oldReminder: 0 }); const broadcast = vi.fn(); const s = svc({ update, broadcast } as Partial); - expect(await new TripsController(s).update(user, '9', { title: 'b' }, req, 'sock')).toEqual({ trip: { id: 9 } }); + expect(await tc(s).update(user, '9', { title: 'b' }, req, 'sock')).toEqual({ trip: { id: 9 } }); expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9 } }, 'sock'); }); it('403 on cover_image without trip_cover_upload', async () => { const s = svc({ can: vi.fn().mockImplementation((a: string) => a !== 'trip_cover_upload') }); - expect(await thrownAsync(() => new TripsController(s).update(user, '9', { cover_image: '/x.jpg' }, req))).toEqual({ status: 403, body: { error: 'No permission to change cover image' } }); + expect(await thrownAsync(() => tc(s).update(user, '9', { cover_image: '/x.jpg' }, req))).toEqual({ status: 403, body: { error: 'No permission to change cover image' } }); }); it('403 on an edit field without trip_edit', async () => { const s = svc({ can: vi.fn().mockImplementation((a: string) => a !== 'trip_edit') }); - expect(await thrownAsync(() => new TripsController(s).update(user, '9', { title: 'b' }, req))).toEqual({ status: 403, body: { error: 'No permission to edit this trip' } }); + expect(await thrownAsync(() => tc(s).update(user, '9', { title: 'b' }, req))).toEqual({ status: 403, body: { error: 'No permission to edit this trip' } }); }); it('admin edit logs the owner and reminder changes', async () => { @@ -142,7 +149,7 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { ownerEmail: 'owner@x.y', isAdminEdit: true, newReminder: 5, oldReminder: 0, }); const s = svc({ update } as Partial); - expect(await new TripsController(s).update(user, '9', { title: 'b' }, req)).toEqual({ trip: { id: 9 } }); + expect(await tc(s).update(user, '9', { title: 'b' }, req)).toEqual({ trip: { id: 9 } }); }); it('logs when a reminder is removed', async () => { @@ -150,26 +157,26 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { updatedTrip: { id: 9 }, changes: {}, newTitle: 'b', newReminder: 0, oldReminder: 5, }); const s = svc({ update } as Partial); - expect(await new TripsController(s).update(user, '9', { reminder_days: 0 }, req)).toEqual({ trip: { id: 9 } }); + expect(await tc(s).update(user, '9', { reminder_days: 0 }, req)).toEqual({ trip: { id: 9 } }); }); it('maps a NotFoundError to 404 and a ValidationError to 400', async () => { const nf = svc({ update: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) } as Partial); - expect(await thrownAsync(() => new TripsController(nf).update(user, '9', { title: 'b' }, req))).toEqual({ status: 404, body: { error: 'gone' } }); + expect(await thrownAsync(() => tc(nf).update(user, '9', { title: 'b' }, req))).toEqual({ status: 404, body: { error: 'gone' } }); const ve = svc({ update: vi.fn().mockImplementation(() => { throw new ValidationError('bad'); }) } as Partial); - expect(await thrownAsync(() => new TripsController(ve).update(user, '9', { title: 'b' }, req))).toEqual({ status: 400, body: { error: 'bad' } }); + expect(await thrownAsync(() => tc(ve).update(user, '9', { title: 'b' }, req))).toEqual({ status: 400, body: { error: 'bad' } }); }); it('re-throws an unknown error from update', async () => { const s = svc({ update: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial); - await expect(new TripsController(s).update(user, '9', { title: 'b' }, req)).rejects.toThrow('boom'); + await expect(tc(s).update(user, '9', { title: 'b' }, req)).rejects.toThrow('boom'); }); it('#1277: internalises an Unsplash cover hot-link into uploads/covers before saving', async () => { const update = vi.fn().mockReturnValue({ updatedTrip: { id: 9 }, changes: {}, newTitle: 'b', newReminder: 0, oldReminder: 0 }); const deleteOldCover = vi.fn(); const s = svc({ update, deleteOldCover, getRaw: vi.fn().mockReturnValue({ cover_image: null }) } as Partial); - await new TripsController(s).update(user, '9', { cover_image: 'https://images.unsplash.com/photo-123?w=1080' }, req); + await tc(s).update(user, '9', { cover_image: 'https://images.unsplash.com/photo-123?w=1080' }, req); // The handler downloads the cover and rewrites cover_image to a local path // before delegating to update(); on download failure it would have thrown 502. const savedBody = update.mock.calls[0][2] as { cover_image: string }; @@ -179,27 +186,27 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { describe('POST /:id/copy', () => { it('403 without trip_create, 404 without access', () => { - expect(thrown(() => new TripsController(svc({ can: vi.fn().mockReturnValue(false) })).copy(user, '9', undefined, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); - expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).copy(user, '9', undefined, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).copy(user, '9', undefined, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); + expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).copy(user, '9', undefined, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); it('copies + returns the new trip', () => { const s = svc({ copy: vi.fn().mockReturnValue(42), getCopiedTrip: vi.fn().mockReturnValue({ id: 42 }) } as Partial); - expect(new TripsController(s).copy(user, '9', 'Copy', req)).toEqual({ trip: { id: 42 } }); + expect(tc(s).copy(user, '9', 'Copy', req)).toEqual({ trip: { id: 42 } }); }); }); describe('DELETE /:id', () => { it('404 when no owner, 403 without trip_delete', () => { - expect(thrown(() => new TripsController(svc({ getOwner: vi.fn().mockReturnValue(undefined) } as Partial)).remove(user, '9', req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(svc({ getOwner: vi.fn().mockReturnValue(undefined) } as Partial)).remove(user, '9', req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 1 }), can: vi.fn().mockReturnValue(false) } as Partial); - expect(thrown(() => new TripsController(s).remove(user, '9', req))).toEqual({ status: 403, body: { error: 'No permission to delete this trip' } }); + expect(thrown(() => tc(s).remove(user, '9', req))).toEqual({ status: 403, body: { error: 'No permission to delete this trip' } }); }); it('deletes, audits and broadcasts', () => { const remove = vi.fn().mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: false }); const broadcast = vi.fn(); const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 1 }), remove, broadcast } as Partial); - expect(new TripsController(s).remove(user, '9', req, 'sock')).toEqual({ success: true }); + expect(tc(s).remove(user, '9', req, 'sock')).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('9', 'trip:deleted', { id: 9 }, 'sock'); }); @@ -207,70 +214,70 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { const remove = vi.fn().mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: true, ownerEmail: 'owner@x.y' }); const broadcast = vi.fn(); const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 2 }), remove, broadcast } as Partial); - expect(new TripsController(s).remove(user, '9', req)).toEqual({ success: true }); + expect(tc(s).remove(user, '9', req)).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('9', 'trip:deleted', { id: 9 }, undefined); }); }); describe('members', () => { it('GET 404 without access, else owner+members+current_user_id', () => { - expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).members(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).members(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); const s = svc({ listMembers: vi.fn().mockReturnValue({ owner: { id: 1 }, members: [] }) } as Partial); - expect(new TripsController(s).members(user, '9')).toEqual({ owner: { id: 1 }, members: [], current_user_id: 1 }); + expect(tc(s).members(user, '9')).toEqual({ owner: { id: 1 }, members: [], current_user_id: 1 }); }); it('POST 403 without member_manage, else adds + notifies', () => { - expect(thrown(() => new TripsController(svc({ can: vi.fn().mockReturnValue(false) })).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 403, body: { error: 'No permission to manage members' } }); + expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 403, body: { error: 'No permission to manage members' } }); const addMember = vi.fn().mockReturnValue({ member: { id: 2, email: 'bob@x.y' }, targetUserId: 2, tripTitle: 'T' }); const notifyInvite = vi.fn(); const s = svc({ addMember, notifyInvite } as Partial); - expect(new TripsController(s).addMember(user, '9', 'bob@x.y')).toEqual({ member: { id: 2, email: 'bob@x.y' } }); + expect(tc(s).addMember(user, '9', 'bob@x.y')).toEqual({ member: { id: 2, email: 'bob@x.y' } }); expect(notifyInvite).toHaveBeenCalledWith('9', user, 2, 'T', 'bob@x.y'); }); it('POST 404 without trip access', () => { const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) }); - expect(thrown(() => new TripsController(s).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(s).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); it('POST maps NotFoundError to 404, ValidationError to 400, re-throws others', () => { const nf = svc({ addMember: vi.fn().mockImplementation(() => { throw new NotFoundError('no user'); }) } as Partial); - expect(thrown(() => new TripsController(nf).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 404, body: { error: 'no user' } }); + expect(thrown(() => tc(nf).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 404, body: { error: 'no user' } }); const ve = svc({ addMember: vi.fn().mockImplementation(() => { throw new ValidationError('already a member'); }) } as Partial); - expect(thrown(() => new TripsController(ve).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 400, body: { error: 'already a member' } }); + expect(thrown(() => tc(ve).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 400, body: { error: 'already a member' } }); const other = svc({ addMember: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial); - expect(() => new TripsController(other).addMember(user, '9', 'bob@x.y')).toThrow('boom'); + expect(() => tc(other).addMember(user, '9', 'bob@x.y')).toThrow('boom'); }); it('DELETE 404 without trip access', () => { const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) }); - expect(thrown(() => new TripsController(s).removeMember(user, '9', '2'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(s).removeMember(user, '9', '2'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); it('DELETE self needs no permission; removing others needs member_manage', () => { const removeMember = vi.fn(); const s = svc({ can: vi.fn().mockReturnValue(false), removeMember } as Partial); // self-removal (targetId === user.id) bypasses the permission check - expect(new TripsController(s).removeMember(user, '9', '1')).toEqual({ success: true }); - expect(thrown(() => new TripsController(s).removeMember(user, '9', '2'))).toEqual({ status: 403, body: { error: 'No permission to remove members' } }); + expect(tc(s).removeMember(user, '9', '1')).toEqual({ success: true }); + expect(thrown(() => tc(s).removeMember(user, '9', '2'))).toEqual({ status: 403, body: { error: 'No permission to remove members' } }); }); }); describe('POST /:id/transfer (#973)', () => { it('404 without trip access', () => { const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) }); - expect(thrown(() => new TripsController(s).transferOwnership(user, '9', 2, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(s).transferOwnership(user, '9', 2, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); it('403 when the requester is not the owner', () => { // access.user_id (5) differs from the requesting user (1) const s = svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) }); - expect(thrown(() => new TripsController(s).transferOwnership(user, '9', 2, req))).toEqual({ status: 403, body: { error: 'Only the owner can transfer ownership' } }); + expect(thrown(() => tc(s).transferOwnership(user, '9', 2, req))).toEqual({ status: 403, body: { error: 'Only the owner can transfer ownership' } }); }); it('400 when newOwnerId is not a number', () => { const s = svc(); - expect(thrown(() => new TripsController(s).transferOwnership(user, '9', 'nope' as unknown as number, req))).toEqual({ status: 400, body: { error: 'newOwnerId is required' } }); + expect(thrown(() => tc(s).transferOwnership(user, '9', 'nope' as unknown as number, req))).toEqual({ status: 400, body: { error: 'newOwnerId is required' } }); }); it('transfers, audits and broadcasts the refreshed trip', () => { @@ -278,70 +285,70 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { const get = vi.fn().mockReturnValue({ id: 9, user_id: 2 }); const broadcast = vi.fn(); const s = svc({ transferOwnership, get, broadcast } as Partial); - expect(new TripsController(s).transferOwnership(user, '9', 2, req, 'sock')).toEqual({ success: true }); + expect(tc(s).transferOwnership(user, '9', 2, req, 'sock')).toEqual({ success: true }); expect(transferOwnership).toHaveBeenCalledWith('9', 2, user.id); expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9, user_id: 2 } }, 'sock'); }); it('maps NotFoundError to 404 and ValidationError to 400', () => { const nf = svc({ transferOwnership: vi.fn().mockImplementation(() => { throw new NotFoundError('User not found'); }) } as Partial); - expect(thrown(() => new TripsController(nf).transferOwnership(user, '9', 2, req))).toEqual({ status: 404, body: { error: 'User not found' } }); + expect(thrown(() => tc(nf).transferOwnership(user, '9', 2, req))).toEqual({ status: 404, body: { error: 'User not found' } }); const ve = svc({ transferOwnership: vi.fn().mockImplementation(() => { throw new ValidationError('New owner must be a trip member'); }) } as Partial); - expect(thrown(() => new TripsController(ve).transferOwnership(user, '9', 2, req))).toEqual({ status: 400, body: { error: 'New owner must be a trip member' } }); + expect(thrown(() => tc(ve).transferOwnership(user, '9', 2, req))).toEqual({ status: 400, body: { error: 'New owner must be a trip member' } }); }); }); describe('guests (#1362)', () => { it('404 without access, 403 for a non-owner, 400 without a name; else creates', () => { - expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).createGuest(user, '9', 'Anna'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).createGuest(user, '9', 'Anna'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); // access.user_id (5) ≠ requester (1) → not the owner - expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).createGuest(user, '9', 'Anna'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); - expect(thrown(() => new TripsController(svc()).createGuest(user, '9', ' '))).toEqual({ status: 400, body: { error: 'Guest name is required' } }); + expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).createGuest(user, '9', 'Anna'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); + expect(thrown(() => tc(svc()).createGuest(user, '9', ' '))).toEqual({ status: 400, body: { error: 'Guest name is required' } }); const createGuest = vi.fn().mockReturnValue({ member: { id: 7, username: 'Anna', is_guest: true } }); const s = svc({ createGuest } as Partial); - expect(new TripsController(s).createGuest(user, '9', 'Anna')).toEqual({ member: { id: 7, username: 'Anna', is_guest: true } }); + expect(tc(s).createGuest(user, '9', 'Anna')).toEqual({ member: { id: 7, username: 'Anna', is_guest: true } }); expect(createGuest).toHaveBeenCalledWith('9', 'Anna', user.id); }); it('rename: 403 non-owner, 404 when the guest is missing, else success', () => { - expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).renameGuest(user, '9', '7', 'Bob'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); + expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).renameGuest(user, '9', '7', 'Bob'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); const miss = svc({ renameGuest: vi.fn().mockReturnValue(false) } as Partial); - expect(thrown(() => new TripsController(miss).renameGuest(user, '9', '7', 'Bob'))).toEqual({ status: 404, body: { error: 'Guest not found' } }); + expect(thrown(() => tc(miss).renameGuest(user, '9', '7', 'Bob'))).toEqual({ status: 404, body: { error: 'Guest not found' } }); const ok = svc({ renameGuest: vi.fn().mockReturnValue(true) } as Partial); - expect(new TripsController(ok).renameGuest(user, '9', '7', 'Bob')).toEqual({ success: true }); + expect(tc(ok).renameGuest(user, '9', '7', 'Bob')).toEqual({ success: true }); }); it('delete: 403 non-owner, 404 when the guest is missing, else success', () => { - expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).deleteGuest(user, '9', '7'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); + expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).deleteGuest(user, '9', '7'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); const miss = svc({ deleteGuest: vi.fn().mockReturnValue(false) } as Partial); - expect(thrown(() => new TripsController(miss).deleteGuest(user, '9', '7'))).toEqual({ status: 404, body: { error: 'Guest not found' } }); + expect(thrown(() => tc(miss).deleteGuest(user, '9', '7'))).toEqual({ status: 404, body: { error: 'Guest not found' } }); const ok = svc({ deleteGuest: vi.fn().mockReturnValue(true) } as Partial); - expect(new TripsController(ok).deleteGuest(user, '9', '7')).toEqual({ success: true }); + expect(tc(ok).deleteGuest(user, '9', '7')).toEqual({ success: true }); }); it('maps a ValidationError from createGuest to 400', () => { const ve = svc({ createGuest: vi.fn().mockImplementation(() => { throw new ValidationError('Guest name must be 50 characters or fewer'); }) } as Partial); - expect(thrown(() => new TripsController(ve).createGuest(user, '9', 'x'.repeat(60)))).toEqual({ status: 400, body: { error: 'Guest name must be 50 characters or fewer' } }); + expect(thrown(() => tc(ve).createGuest(user, '9', 'x'.repeat(60)))).toEqual({ status: 400, body: { error: 'Guest name must be 50 characters or fewer' } }); }); }); it('GET /:id/bundle 404 then aggregates', () => { - expect(thrown(() => new TripsController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial)).bundle(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial)).bundle(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); const bundle = vi.fn().mockReturnValue({ trip: { id: 9 }, days: [] }); const s = svc({ get: vi.fn().mockReturnValue({ user_id: 1 }), bundle } as Partial); - expect(new TripsController(s).bundle(user, '9')).toEqual({ trip: { id: 9 }, days: [] }); + expect(tc(s).bundle(user, '9')).toEqual({ trip: { id: 9 }, days: [] }); }); describe('POST /:id/cover', () => { const file = { filename: 'abc.jpg' } as Express.Multer.File; it('404 without access, 403 without permission, 404 raw trip, 400 no file, else returns url', () => { - expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).cover(user, '9', file))).toEqual({ status: 404, body: { error: 'Trip not found' } }); - expect(thrown(() => new TripsController(svc({ can: vi.fn().mockReturnValue(false) })).cover(user, '9', file))).toEqual({ status: 403, body: { error: 'No permission to change the cover image' } }); - expect(thrown(() => new TripsController(svc({ getRaw: vi.fn().mockReturnValue(undefined) } as Partial)).cover(user, '9', file))).toEqual({ status: 404, body: { error: 'Trip not found' } }); - expect(thrown(() => new TripsController(svc({ getRaw: vi.fn().mockReturnValue({ cover_image: null }) } as Partial)).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } }); + expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).cover(user, '9', file))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).cover(user, '9', file))).toEqual({ status: 403, body: { error: 'No permission to change the cover image' } }); + expect(thrown(() => tc(svc({ getRaw: vi.fn().mockReturnValue(undefined) } as Partial)).cover(user, '9', file))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(svc({ getRaw: vi.fn().mockReturnValue({ cover_image: null }) } as Partial)).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } }); const deleteOldCover = vi.fn(); const updateCoverImage = vi.fn(); const s = svc({ getRaw: vi.fn().mockReturnValue({ cover_image: '/old.jpg' }), deleteOldCover, updateCoverImage } as Partial); - expect(new TripsController(s).cover(user, '9', file)).toEqual({ cover_image: '/uploads/covers/abc.jpg' }); + expect(tc(s).cover(user, '9', file)).toEqual({ cover_image: '/uploads/covers/abc.jpg' }); expect(deleteOldCover).toHaveBeenCalledWith('/old.jpg'); expect(updateCoverImage).toHaveBeenCalledWith('9', '/uploads/covers/abc.jpg'); }); @@ -351,7 +358,7 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { process.env.DEMO_MODE = 'true'; isDemoEmail.mockReturnValueOnce(true); try { - expect(thrown(() => new TripsController(svc()).cover(user, '9', file))).toEqual({ + expect(thrown(() => tc(svc()).cover(user, '9', file))).toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, }); } finally { @@ -364,10 +371,10 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { describe('GET /:id/export.ics', () => { function makeRes() { return { setHeader: vi.fn(), send: vi.fn() } as never; } it('404 without access, else sends the calendar with headers', () => { - expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'Trip not found' } }); const res = { setHeader: vi.fn(), send: vi.fn() }; const s = svc({ exportICS: vi.fn().mockReturnValue({ ics: 'BEGIN:VCALENDAR', filename: 'trip.ics' }) } as Partial); - new TripsController(s).exportIcs(user, '9', res as never); + tc(s).exportIcs(user, '9', res as never); expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/calendar; charset=utf-8'); expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="trip.ics"'); expect(res.send).toHaveBeenCalledWith('BEGIN:VCALENDAR'); @@ -375,14 +382,14 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { it('maps a NotFoundError from the export to 404 and re-throws others', () => { const nf = svc({ exportICS: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) } as Partial); - expect(thrown(() => new TripsController(nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } }); + expect(thrown(() => tc(nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } }); const other = svc({ exportICS: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial); - expect(() => new TripsController(other).exportIcs(user, '9', makeRes())).toThrow('boom'); + expect(() => tc(other).exportIcs(user, '9', makeRes())).toThrow('boom'); }); }); it('POST /:id/copy maps a copy failure to 500', () => { const s = svc({ copy: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial); - expect(thrown(() => new TripsController(s).copy(user, '9', undefined, req))).toEqual({ status: 500, body: { error: 'Failed to copy trip' } }); + expect(thrown(() => tc(s).copy(user, '9', undefined, req))).toEqual({ status: 500, body: { error: 'Failed to copy trip' } }); }); }); diff --git a/server/tests/unit/plugins/settings-isolation.test.ts b/server/tests/unit/plugins/settings-isolation.test.ts index 7c888a03a..f7fa9536b 100644 --- a/server/tests/unit/plugins/settings-isolation.test.ts +++ b/server/tests/unit/plugins/settings-isolation.test.ts @@ -19,6 +19,7 @@ const { testDb, dbMock } = vi.hoisted(() => { vi.mock('../../../src/db/database', () => dbMock); import { db as dbConn } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; +import { AuditService } from '../../../src/nest/audit/audit.service'; vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} })); import { createTables } from '../../../src/db/schema'; @@ -122,7 +123,7 @@ describe('a plugin channel label is bounded by the host', () => { VALUES ('loud', 'Loud', 'active', 1, '1.0.0', '[]', '[]', ?, '{}')`, ).run(JSON.stringify({ notificationChannel: { title: '🎉'.repeat(5) + 'A'.repeat(500) } })); - const rt = new PluginRuntimeService(new DatabaseService(dbConn)); + const rt = new PluginRuntimeService(new DatabaseService(dbConn), new AuditService(new DatabaseService(dbConn))); // Stand the plugin up as a granted, active notificationChannel provider. (rt as unknown as { supervisor: { running: Map } }).supervisor.running.set('loud', { id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']), diff --git a/server/tests/unit/scheduler.test.ts b/server/tests/unit/scheduler.test.ts index 69a4a2fa7..aea4d9455 100644 --- a/server/tests/unit/scheduler.test.ts +++ b/server/tests/unit/scheduler.test.ts @@ -33,7 +33,7 @@ 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) })); -vi.mock('../../src/services/auditLog', () => ({ +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ logInfo: vi.fn(), logError: vi.fn(), })); diff --git a/server/tests/unit/services/airtrailWriteGate.test.ts b/server/tests/unit/services/airtrailWriteGate.test.ts index 5a2c56cdc..c28cf5cb7 100644 --- a/server/tests/unit/services/airtrailWriteGate.test.ts +++ b/server/tests/unit/services/airtrailWriteGate.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../../../src/db/database', () => ({ db: { prepare: vi.fn() } })); vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: vi.fn(() => true) })); -vi.mock('../../../src/services/auditLog', () => ({ logError: vi.fn(), logInfo: vi.fn() })); +vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ logError: vi.fn(), logInfo: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); vi.mock('../../../src/nest/reservations/reservations.bridge', () => ({ getReservation: vi.fn(), diff --git a/server/tests/unit/services/auditLog.test.ts b/server/tests/unit/services/auditLog.test.ts deleted file mode 100644 index aa0c1c7cf..000000000 --- a/server/tests/unit/services/auditLog.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -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(); - }); -}); diff --git a/server/tests/unit/services/notifications.test.ts b/server/tests/unit/services/notifications.test.ts index 72051a732..c115b7a91 100644 --- a/server/tests/unit/services/notifications.test.ts +++ b/server/tests/unit/services/notifications.test.ts @@ -7,7 +7,7 @@ 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', () => ({ +vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), @@ -26,7 +26,7 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({ import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook, sendNtfy, resolveNtfyUrl, resolveAdminNtfyUrl, type NtfyConfig } from '../../../src/services/notifications'; import { checkSsrf } from '../../../src/utils/ssrfGuard'; -import { logError } from '../../../src/services/auditLog'; +import { logError } from '../../../src/nest/audit/audit-log.logger'; afterEach(() => { vi.unstubAllEnvs();