chore: move auditLog backend to proper DI

This commit is contained in:
jubnl
2026-07-27 22:09:08 +02:00
parent 1d56de3897
commit 6ff936c613
59 changed files with 1077 additions and 658 deletions
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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 };
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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';
/**
+23 -20
View File
@@ -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<AdminService['savePermissions']>[0]);
writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record<string, unknown> });
this.audit.writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record<string, unknown> });
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<string, unknown>);
writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', ip: getClientIp(req), details: body as Record<string, unknown> });
this.audit.writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', ip: getClientIp(req), details: body as Record<string, unknown> });
return this.admin.getAdminUserDefaults();
} catch (err) {
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
+2 -1
View File
@@ -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],
})
+2 -1
View File
@@ -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,
+85
View File
@@ -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 };
+26
View File
@@ -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<AuditService['writeAudit']>[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';
+15
View File
@@ -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 {}
+89
View File
@@ -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<string, string> = {
'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, unknown>): 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<string, unknown>;
debugDetails?: Record<string, unknown>;
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}`);
}
}
}
+16
View File
@@ -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;
}
+12 -11
View File
@@ -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 };
}
+8 -7
View File
@@ -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 };
}
+2
View File
@@ -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],
})
+7 -6
View File
@@ -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<typeof passkey.passkeyRegisterVerify>[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<typeof passkey.passkeyLoginVerify>[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 };
}
}
+8 -7
View File
@@ -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<string, unknown>, @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 };
}
}
+2
View File
@@ -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],
})
@@ -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,
@@ -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
@@ -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';
@@ -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;
}
+2
View File
@@ -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],
})
@@ -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,
@@ -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';
+2 -1
View File
@@ -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
@@ -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 };
}
}
@@ -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],
})
+9 -7
View File
@@ -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);
+2 -1
View File
@@ -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],
})
+1 -1
View File
@@ -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');
@@ -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';
+1 -1
View File
@@ -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,
-171
View File
@@ -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<string, string> = {
'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<string, unknown>;
debugDetails?: Record<string, unknown>;
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, unknown>): 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 };
@@ -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';
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+8 -1
View File
@@ -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) }));
+10 -2
View File
@@ -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<ReturnType<typeof build>>;
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());
+10 -2
View File
@@ -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<ReturnType<typeof build>>;
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());
+10 -2
View File
@@ -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<ReturnType<typeof build>>;
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());
+8 -1
View File
@@ -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';
+11 -1
View File
@@ -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);
+2 -1
View File
@@ -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));
}
+87 -79
View File
@@ -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> = {}): 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<AdminService>)).listUsers()).toEqual({ users: [{ id: 1 }] });
expect(thrown(() => new AdminController(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AdminService>)).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<AdminService>));
expect(adminCtl(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listUsers()).toEqual({ users: [{ id: 1 }] });
expect(thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AdminService>)).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<AdminService>));
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<AdminService>)).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<AdminService>)).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<AdminService>)).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<AdminService>)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } });
expect(thrown(() => adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ error: 'Cannot delete self', status: 400 }) } as Partial<AdminService>)).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<AdminService>)).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<AdminService>));
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<AdminService>));
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<AdminService>));
const c = adminCtl(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: {}, skipped: ['bad'] }) } as Partial<AdminService>));
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<AdminService>)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'bad issuer' } });
expect(new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).updateOidc(user, { issuer: 'https://idp' }, req)).toEqual({ success: true });
expect(thrown(() => adminCtl(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'bad issuer', status: 400 }) } as Partial<AdminService>)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'bad issuer' } });
expect(adminCtl(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).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<AdminService>)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } });
expect(new AdminController(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial<AdminService>)).saveDemoBaseline(user, req)).toEqual({ success: true, message: 'saved' });
expect(thrown(() => adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ error: 'not demo', status: 400 }) } as Partial<AdminService>)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } });
expect(adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial<AdminService>)).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<AdminService>));
const c = adminCtl(svc({ createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) } as Partial<AdminService>));
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<AdminService>)).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<AdminService>)).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<AdminService>)).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<AdminService>)).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<AdminService>));
const c = adminCtl(svc({ updateCollabFeatures: vi.fn().mockReturnValue({ features: { chat: true }, changed: true }), invalidateMcpSessions } as Partial<AdminService>));
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<AdminService>));
const noop = adminCtl(svc({ updateCollabFeatures: vi.fn().mockReturnValue({ features: { chat: true }, changed: false }), invalidateMcpSessions: noopInvalidate } as Partial<AdminService>));
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<AdminService>)).getPackingTemplate('9'))).toEqual({ status: 404, body: { error: 'not found' } });
expect(new AdminController(svc({ createPackingTemplate: vi.fn().mockReturnValue({ id: 3, name: 'Beach' }) } as Partial<AdminService>)).createPackingTemplate(user, { name: 'Beach' })).toEqual({ id: 3, name: 'Beach' });
expect(new AdminController(svc({ deletePackingTemplate: vi.fn().mockReturnValue({ name: 'Beach' }) } as Partial<AdminService>)).deletePackingTemplate(user, '3', req)).toEqual({ success: true });
expect(new AdminController(svc({ createTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial<AdminService>)).createTemplateItem('3', '4', { name: 'Towel' })).toEqual({ id: 7 });
expect(thrown(() => adminCtl(svc({ getPackingTemplate: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial<AdminService>)).getPackingTemplate('9'))).toEqual({ status: 404, body: { error: 'not found' } });
expect(adminCtl(svc({ createPackingTemplate: vi.fn().mockReturnValue({ id: 3, name: 'Beach' }) } as Partial<AdminService>)).createPackingTemplate(user, { name: 'Beach' })).toEqual({ id: 3, name: 'Beach' });
expect(adminCtl(svc({ deletePackingTemplate: vi.fn().mockReturnValue({ name: 'Beach' }) } as Partial<AdminService>)).deletePackingTemplate(user, '3', req)).toEqual({ success: true });
expect(adminCtl(svc({ createTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial<AdminService>)).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<AdminService>), runtime);
const c = adminCtl(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'mcp', enabled: true }, mcpAffected: true, auditDetails: {} }), invalidateMcpSessions } as Partial<AdminService>), 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<AdminService>), runtime);
const noop = adminCtl(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'llm_parsing', enabled: true }, mcpAffected: false, auditDetails: {} }), invalidateMcpSessions: noopInvalidate } as Partial<AdminService>), 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<AdminService>), runtime2);
const disable = adminCtl(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'budget', enabled: false }, mcpAffected: true, auditDetails: {} }), invalidateMcpSessions: vi.fn() } as Partial<AdminService>), 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<AdminService>)).revokeOAuthSession(user, '3', req)).toEqual({ success: true });
expect(thrown(() => new AdminController(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial<AdminService>)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } });
expect(new AdminController(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).rotateJwtSecret(user, req)).toEqual({ success: true });
expect(adminCtl(svc({ revokeOAuthSession: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).revokeOAuthSession(user, '3', req)).toEqual({ success: true });
expect(thrown(() => adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial<AdminService>)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } });
expect(adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).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<AdminService>));
const c = adminCtl(svc({ setAdminUserDefaults, getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<AdminService>));
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<AdminService>)).createUser(user, {}, req))).toEqual({ status: 400, body: { error: 'boom' } });
expect(thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'boom' }) } as Partial<AdminService>)).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<AdminService>)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'nope' } });
expect(thrown(() => adminCtl(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'nope' }) } as Partial<AdminService>)).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<AdminService>)).updateOidc(user, {}, req)).toEqual({ success: true });
expect(adminCtl(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).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<AdminService>)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 });
expect(new AdminController(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial<AdminService>)).stats()).toEqual({ users: 3 });
expect(new AdminController(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial<AdminService>)).permissions()).toEqual({ a: 1 });
expect(new AdminController(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog({})).toEqual({ entries: [] });
expect(new AdminController(svc({ getOidcSettings: vi.fn().mockReturnValue({ issuer: 'x' }) } as Partial<AdminService>)).getOidc()).toEqual({ issuer: 'x' });
expect(new AdminController(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
expect(new AdminController(svc({ getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [] }) } as Partial<AdminService>)).getNotificationPrefs(user)).toEqual({ rows: [] });
expect(new AdminController(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listInvites()).toEqual({ invites: [{ id: 1 }] });
expect(new AdminController(svc({ getBagTracking: vi.fn().mockReturnValue({ enabled: false }) } as Partial<AdminService>)).getBagTracking()).toEqual({ enabled: false });
expect(new AdminController(svc({ getPlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesPhotos()).toEqual({ enabled: true });
expect(new AdminController(svc({ getPlacesAutocomplete: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesAutocomplete()).toEqual({ enabled: true });
expect(new AdminController(svc({ getPlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesDetails()).toEqual({ enabled: true });
expect(new AdminController(svc({ getCollabFeatures: vi.fn().mockReturnValue({ chat: false }) } as Partial<AdminService>)).getCollabFeatures()).toEqual({ chat: false });
expect(new AdminController(svc({ listPackingTemplates: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listPackingTemplates()).toEqual({ templates: [{ id: 1 }] });
expect(new AdminController(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
expect(new AdminController(svc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
expect(new AdminController(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
expect(new AdminController(svc({ getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<AdminService>)).getDefaultUserSettings()).toEqual({ theme: 'dark' });
expect(adminCtl(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial<AdminService>)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 });
expect(adminCtl(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial<AdminService>)).stats()).toEqual({ users: 3 });
expect(adminCtl(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial<AdminService>)).permissions()).toEqual({ a: 1 });
expect(adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog({})).toEqual({ entries: [] });
expect(adminCtl(svc({ getOidcSettings: vi.fn().mockReturnValue({ issuer: 'x' }) } as Partial<AdminService>)).getOidc()).toEqual({ issuer: 'x' });
expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
expect(adminCtl(svc({ getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [] }) } as Partial<AdminService>)).getNotificationPrefs(user)).toEqual({ rows: [] });
expect(adminCtl(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listInvites()).toEqual({ invites: [{ id: 1 }] });
expect(adminCtl(svc({ getBagTracking: vi.fn().mockReturnValue({ enabled: false }) } as Partial<AdminService>)).getBagTracking()).toEqual({ enabled: false });
expect(adminCtl(svc({ getPlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesPhotos()).toEqual({ enabled: true });
expect(adminCtl(svc({ getPlacesAutocomplete: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesAutocomplete()).toEqual({ enabled: true });
expect(adminCtl(svc({ getPlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesDetails()).toEqual({ enabled: true });
expect(adminCtl(svc({ getCollabFeatures: vi.fn().mockReturnValue({ chat: false }) } as Partial<AdminService>)).getCollabFeatures()).toEqual({ chat: false });
expect(adminCtl(svc({ listPackingTemplates: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listPackingTemplates()).toEqual({ templates: [{ id: 1 }] });
expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
expect(adminCtl(svc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
expect(adminCtl(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
expect(adminCtl(svc({ getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<AdminService>)).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<AdminService>));
const c = adminCtl(svc({ setAdminPreferences, getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [1] }) } as Partial<AdminService>));
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<AdminService>));
const c = adminCtl(svc({ getGithubReleases } as Partial<AdminService>));
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<AdminService>));
const c = adminCtl(svc({ updateBagTracking: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>));
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<AdminService>)).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<AdminService>)).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<AdminService>)).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<AdminService>)).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<AdminService>)).updatePackingTemplate('3', {})).toEqual({ id: 3 });
expect(new AdminController(svc({ createTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial<AdminService>)).createTemplateCategory('3', { name: 'Tops' })).toEqual({ id: 4 });
expect(new AdminController(svc({ updateTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial<AdminService>)).updateTemplateCategory('3', '4', {})).toEqual({ id: 4 });
expect(new AdminController(svc({ deleteTemplateCategory: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteTemplateCategory('3', '4')).toEqual({ success: true });
expect(new AdminController(svc({ updateTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial<AdminService>)).updateTemplateItem('7', {})).toEqual({ id: 7 });
expect(new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteTemplateItem('7')).toEqual({ success: true });
expect(thrown(() => new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({ error: 'gone', status: 404 }) } as Partial<AdminService>)).deleteTemplateItem('9'))).toEqual({ status: 404, body: { error: 'gone' } });
expect(adminCtl(svc({ updatePackingTemplate: vi.fn().mockReturnValue({ id: 3 }) } as Partial<AdminService>)).updatePackingTemplate('3', {})).toEqual({ id: 3 });
expect(adminCtl(svc({ createTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial<AdminService>)).createTemplateCategory('3', { name: 'Tops' })).toEqual({ id: 4 });
expect(adminCtl(svc({ updateTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial<AdminService>)).updateTemplateCategory('3', '4', {})).toEqual({ id: 4 });
expect(adminCtl(svc({ deleteTemplateCategory: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteTemplateCategory('3', '4')).toEqual({ success: true });
expect(adminCtl(svc({ updateTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial<AdminService>)).updateTemplateItem('7', {})).toEqual({ id: 7 });
expect(adminCtl(svc({ deleteTemplateItem: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteTemplateItem('7')).toEqual({ success: true });
expect(thrown(() => adminCtl(svc({ deleteTemplateItem: vi.fn().mockReturnValue({ error: 'gone', status: 404 }) } as Partial<AdminService>)).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<AdminService>)).deleteMcpToken('2')).toEqual({ success: true });
expect(thrown(() => new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) } as Partial<AdminService>)).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<AdminService>)).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } });
expect(adminCtl(svc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteMcpToken('2')).toEqual({ success: true });
expect(thrown(() => adminCtl(svc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) } as Partial<AdminService>)).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } });
expect(thrown(() => adminCtl(svc({ revokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) } as Partial<AdminService>)).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<AdminService>));
const c = adminCtl(svc({ setAdminUserDefaults: vi.fn(() => { throw new Error('bad default'); }) } as Partial<AdminService>));
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<AdminService>));
const c = adminCtl(svc({ setAdminUserDefaults: vi.fn(() => { throw 'plain string'; }) } as Partial<AdminService>));
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<typeof vi.fn>).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<typeof vi.fn>).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' } });
});
});
@@ -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<typeof fs.statSync>);
});
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<typeof fs.statSync>);
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<typeof fs.statSync>);
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);
});
});
@@ -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);
});
});
+25 -18
View File
@@ -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' });
});
});
+76 -68
View File
@@ -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> = {}): 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<AuthService>), 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<AuthService>), 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<AuthService>), rl());
const c = apc(asvc({ demoLogin: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), rl());
const c = apc(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial<AuthService>), 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<AuthService>), s);
const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null }) } as Partial<AuthService>), 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<AuthService>), rl());
const mfa = apc(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial<AuthService>), 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<AuthService>), rl());
const ok = apc(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user, remember: true }), setAuthCookie } as Partial<AuthService>), 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<AuthService>), rl());
const bad = apc(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial<AuthService>), 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<AuthService>), rl());
const c = apc(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), rl()).resetPassword({}, req)).toEqual({ mfa_required: true });
expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), rl()).resetPassword({}, req)).toEqual({ success: true });
expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial<AuthService>), rl()).resetPassword({}, req)).toEqual({ mfa_required: true });
expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), 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<AuthService>), rl());
const c = apc(asvc({ getAppConfig } as Partial<AuthService>), 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<AuthService>), rl());
const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ error: 'Expired', status: 410 }) } as Partial<AuthService>), 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<AuthService>), rl());
const c = apc(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt', auditAction: 'user.login_mfa' }), setAuthCookie } as Partial<AuthService>), 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<AuthService>), rl());
const skip = apc(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'not_found', userId: null }), sendPasswordResetEmail: sendNever } as Partial<AuthService>), 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<AuthService>), rl());
const fail = apc(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail: boom } as Partial<AuthService>), 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<AuthService>), rl());
const c = apc(asvc({ requestPasswordReset } as Partial<AuthService>), 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<AuthService>), s);
const c = apc(asvc({ resetPassword: vi.fn() } as Partial<AuthService>), 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<AuthService>), rl());
const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial<AuthService>), 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<AuthService>), s);
const c = apc(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial<AuthService>), 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<AuthService>), rl());
const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial<AuthService>), rl());
expect(c.verifyMfaLogin({}, req, res)).toEqual({ token: 'tk', user });
expect(setAuthCookie).toHaveBeenCalled();
const clearAuthCookie = vi.fn();
expect(new AuthPublicController(asvc({ clearAuthCookie } as Partial<AuthService>), rl()).logout(req, res)).toEqual({ success: true });
expect(apc(asvc({ clearAuthCookie } as Partial<AuthService>), 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<AuthService>), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } });
expect(new AuthController(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial<AuthService>), rl()).me(user)).toEqual({ user: { id: 1 } });
expect(thrown(() => ac(asvc({ getCurrentUser: vi.fn().mockReturnValue(undefined) } as Partial<AuthService>), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } });
expect(ac(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial<AuthService>), 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<AuthService>), rl()).changePassword(user, {}, req))).toEqual({ status: 400, body: { error: 'Wrong' } });
expect(new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).changePassword(user, {}, req)).toEqual({ success: true });
expect(thrown(() => ac(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial<AuthService>), rl()).changePassword(user, {}, req))).toEqual({ status: 400, body: { error: 'Wrong' } });
expect(ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial<AuthService>), 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<AuthService>), rl()).avatar(user, { filename: 'a.jpg' } as Express.Multer.File)).toEqual({ avatar: '/a.jpg' });
expect(await ac(asvc({ saveAvatar } as Partial<AuthService>), 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('<svg>') }) } as Partial<AuthService>), rl());
const ok = ac(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.resolve('<svg>') }) } as Partial<AuthService>), rl());
expect(await ok.mfaSetup(user)).toEqual({ secret: 's', otpauth_url: 'o', qr_svg: '<svg>' });
const fail = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.reject(new Error('x')) }) } as Partial<AuthService>), rl());
const fail = ac(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.reject(new Error('x')) }) } as Partial<AuthService>), 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<AuthService>), rl());
const enable = ac(asvc({ enableMfa: vi.fn().mockReturnValue({ mfa_enabled: true, backup_codes: ['a', 'b'] }) } as Partial<AuthService>), 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<AuthService>), rl());
const tok = ac(asvc({ createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) } as Partial<AuthService>), 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<AuthService>), rl()).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } });
expect(new AuthController(asvc({ createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) } as Partial<AuthService>), rl()).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' });
expect(thrown(() => ac(asvc({ createResourceToken: vi.fn().mockReturnValue(null) } as Partial<AuthService>), rl()).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } });
expect(ac(asvc({ createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) } as Partial<AuthService>), 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<AuthService>), s);
const c = ac(asvc({ changePassword: vi.fn() } as Partial<AuthService>), 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<AuthService>), rl());
const c = ac(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial<AuthService>), 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<AuthService>), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } });
expect(new AuthController(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).deleteAccount(user, req)).toEqual({ success: true });
expect(thrown(() => ac(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial<AuthService>), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } });
expect(ac(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial<AuthService>), 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<AuthService>), rl()).mapsKey(user, { maps_api_key: 'k' })).toEqual({ success: true });
expect(ac(asvc({ updateMapsKey } as Partial<AuthService>), 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<AuthService>), rl()).apiKeys(user, { a: 1 })).toEqual({ ok: 1 });
expect(ac(asvc({ updateApiKeys } as Partial<AuthService>), 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<AuthService>), rl()).updateSettings(user, {}))).toEqual({ status: 400, body: { error: 'Bad' } });
expect(new AuthController(asvc({ updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) } as Partial<AuthService>), rl()).updateSettings(user, {})).toEqual({ success: true, user: { id: 1 } });
expect(thrown(() => new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) } as Partial<AuthService>), rl()).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } });
expect(new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) } as Partial<AuthService>), rl()).getSettings(user)).toEqual({ settings: { theme: 'dark' } });
expect(thrown(() => ac(asvc({ updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) } as Partial<AuthService>), rl()).updateSettings(user, {}))).toEqual({ status: 400, body: { error: 'Bad' } });
expect(ac(asvc({ updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) } as Partial<AuthService>), rl()).updateSettings(user, {})).toEqual({ success: true, user: { id: 1 } });
expect(thrown(() => ac(asvc({ getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) } as Partial<AuthService>), rl()).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } });
expect(ac(asvc({ getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) } as Partial<AuthService>), 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<AuthService>), rl()).deleteAvatar(user)).toEqual({ removed: true });
expect(await ac(asvc({ deleteAvatar } as Partial<AuthService>), rl()).deleteAvatar(user)).toEqual({ removed: true });
const listUsers = vi.fn().mockReturnValue([{ id: 1 }]);
expect(new AuthController(asvc({ listUsers } as Partial<AuthService>), rl()).users(user)).toEqual({ users: [{ id: 1 }] });
expect(ac(asvc({ listUsers } as Partial<AuthService>), 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<AuthService>), rl()).travelStats(user)).toEqual({ countries: 3 });
expect(ac(asvc({ getTravelStats } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), rl());
expect(await thrownAsync(() => ac(asvc({ validateKeys: vi.fn().mockResolvedValue({ error: 'fail', status: 502 }) } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } });
expect(new AuthController(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial<AuthService>), rl()).getAppSettings(user)).toEqual({ x: 1 });
expect(thrown(() => new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } });
expect(new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true });
expect(thrown(() => ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial<AuthService>), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } });
expect(ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial<AuthService>), rl()).getAppSettings(user)).toEqual({ x: 1 });
expect(thrown(() => ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } });
expect(ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial<AuthService>), 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<AuthService>), rl());
const c = ac(asvc({ setupMfa: vi.fn().mockReturnValue({ error: 'already on', status: 409 }) } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), rl()).mfaDisable(user, {}, req))).toEqual({ status: 401, body: { error: 'Wrong' } });
const ok = new AuthController(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial<AuthService>), rl());
expect(thrown(() => ac(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial<AuthService>), 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<AuthService>), rl()).mfaDisable(user, {}, req))).toEqual({ status: 401, body: { error: 'Wrong' } });
const ok = ac(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial<AuthService>), 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<AuthService>), rl()).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] });
expect(thrown(() => new AuthController(asvc({ createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) } as Partial<AuthService>), 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<AuthService>), rl()).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
expect(new AuthController(asvc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).deleteMcpToken(user, 'tid')).toEqual({ success: true });
expect(ac(asvc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) } as Partial<AuthService>), rl()).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] });
expect(thrown(() => ac(asvc({ createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) } as Partial<AuthService>), 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<AuthService>), rl()).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
expect(ac(asvc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial<AuthService>), 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<AuthService>), rl()).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } });
expect(new AuthController(asvc({ createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) } as Partial<AuthService>), rl()).wsToken(user)).toEqual({ token: 'ws' });
expect(thrown(() => ac(asvc({ createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) } as Partial<AuthService>), rl()).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } });
expect(ac(asvc({ createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) } as Partial<AuthService>), 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<AuthService>), rl()).avatar(user, { filename: 'b.png' } as Express.Multer.File)).toEqual({ avatar: '/b.png' });
expect(await ac(asvc({ saveAvatar } as Partial<AuthService>), rl()).avatar(user, { filename: 'b.png' } as Express.Multer.File)).toEqual({ avatar: '/b.png' });
});
});
@@ -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> = {}): 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<BackupService>)).list()).toEqual({ backups: [{ filename: 'a.zip' }] });
expect(thrown(() => new BackupController(svc({ listBackups: vi.fn(() => { throw new Error('io'); }) } as Partial<BackupService>)).list())).toEqual({ status: 500, body: { error: 'Error loading backups' } });
expect(bc(svc({ listBackups: vi.fn().mockReturnValue([{ filename: 'a.zip' }]) } as Partial<BackupService>)).list()).toEqual({ backups: [{ filename: 'a.zip' }] });
expect(thrown(() => bc(svc({ listBackups: vi.fn(() => { throw new Error('io'); }) } as Partial<BackupService>)).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<BackupService>)).create(user, req);
const res = await bc(svc({ createBackup } as Partial<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).uploadRestore(user, file, req);
const res = await bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).uploadRestore(user, ok, req);
await bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).updateAutoSettings(user, {}, req));
const r = thrown(() => bc(svc({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) } as Partial<BackupService>)).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<BackupService>)).updateAutoSettings(user, {}, req));
const r = thrown(() => bc(svc({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) } as Partial<BackupService>)).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<BackupService>)).updateAutoSettings(user, undefined as unknown as Record<string, unknown>, req);
bc(svc({ updateAutoSettings } as Partial<BackupService>)).updateAutoSettings(user, undefined as unknown as Record<string, unknown>, req);
expect(updateAutoSettings).toHaveBeenCalledWith({});
});
it('GET/PUT /auto-settings', () => {
expect(new BackupController(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial<BackupService>)).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<BackupService>)).updateAutoSettings(user, { enabled: true }, req);
expect(bc(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial<BackupService>)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' });
const res = bc(svc({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) } as Partial<BackupService>)).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<BackupService>)).remove(user, 'x.zip', req)).toEqual({ success: true });
expect(bc(svc({ deleteBackup } as Partial<BackupService>)).remove(user, 'x.zip', req)).toEqual({ success: true });
expect(deleteBackup).toHaveBeenCalledWith('x.zip');
});
});
@@ -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;
+45 -38
View File
@@ -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> = {}): 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<string, string>, 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<string, unknown>, extra: Partial<OauthService> = {}, 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);
});
+78 -71
View File
@@ -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> = {}): 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<TripsService>)).list(user, '1')).toEqual({ trips: [{ id: 1 }] });
expect(tc(svc({ list } as Partial<TripsService>)).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<TripsService>));
const c = tc(svc({ list } as Partial<TripsService>));
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<TripsService>)).create(user, { title: 'T', start_date: '2026-07-01' }, req);
tc(svc({ create } as Partial<TripsService>)).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<TripsService>)).create(user, { title: 'T', end_date: '2026-07-07', day_count: '40' }, req);
tc(svc({ create } as Partial<TripsService>)).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<TripsService>)).create(user, { title: 'T', day_count: 'abc' }, req);
tc(svc({ create } as Partial<TripsService>)).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<TripsService>)).create(user, { title: 'T' }, req)).toEqual({ trip: { id: 9 } });
expect(tc(svc({ create } as Partial<TripsService>)).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<TripsService>)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(thrown(() => tc(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>)).remove(user, '9', req))).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(thrown(() => tc(svc({ getOwner: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
// 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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>)).bundle(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(thrown(() => tc(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).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<TripsService>);
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<TripsService>)).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<TripsService>)).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<TripsService>)).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<TripsService>)).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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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<TripsService>);
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' } });
});
});
@@ -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<string, unknown> } }).supervisor.running.set('loud', {
id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']),
+1 -1
View File
@@ -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(),
}));
@@ -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(),
@@ -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();
});
});
@@ -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();