refactor(audit): audit log writes through AuditLogRepository

This commit is contained in:
jubnl
2026-09-22 12:34:49 +02:00
parent b8e7dbccc6
commit c2da9d62f7
29 changed files with 308 additions and 80 deletions
@@ -1,4 +1,65 @@
import type { AuditLog } from '../entities/AuditLog.entity';
import { type AssertRowKeys } from './_shared/rows';
import { EntityRepository } from '@mikro-orm/sql';
export class AuditLogRepository extends EntityRepository<AuditLog> {}
/**
* An `audit_log` row as the API emits it (Plan 3i's `admin.service.ts`
* projection reads it via a `LEFT JOIN users`, not this shape — this is the
* bare table row).
*/
export interface AuditLogRow {
id: number;
created_at: string | null;
user_id: number | null;
action: string;
resource: string | null;
details: string | null;
ip: string | null;
}
const _auditLogRowKeys: AssertRowKeys<AuditLogRow, AuditLog> = true;
/** The column set `insert` writes; `id`/`created_at` are generated. */
export interface NewAuditLogRow {
user_id: number | null;
action: string;
resource: string | null;
details: string | null;
ip: string | null;
}
// Reserved for Plan 3i (`admin.service.ts`'s paginated read of the same
// table): `listPage(limit, offset): Promise<AuditLogPageRow[]>` (a `qb()`
// join to `Users` for `username`/`email`) and `count(): Promise<number>`.
// Not implemented here — see plan3a-sql-inventory.md §1.
export class AuditLogRepository extends EntityRepository<AuditLog> {
/**
* The column set of the legacy INSERT:
* `INSERT INTO audit_log (user_id, action, resource, details, ip)
* VALUES (?, ?, ?, ?, ?)`.
*
* Best-effort, fire-and-forget, exactly like the caller
* (`AuditService.writeAudit`, wrapped in an outer try/catch that never
* throws): no `refresh`, nothing read back — the generated `id` and
* `created_at` (left to the column's `DEFAULT CURRENT_TIMESTAMP`) are
* never used afterward.
*
* Named `insertEntry`, not `insert`: `EntityRepository` (`@mikro-orm/core`)
* already declares a native `insert(data, options?): Promise<Primary<Entity>>`
* (bypassing `create`/`persist`/`flush`), which is a `tsc`-verified
* incompatible override for a `Promise<void>` method of the same name — the
* same reason `Days.repository.ts`/`DayNotes.repository.ts` name their
* inserts `createDay`/`createNote` rather than `create`.
*/
async insertEntry(entry: NewAuditLogRow): Promise<void> {
const row = this.create({
user: entry.user_id,
action: entry.action,
resource: entry.resource,
details: entry.details,
ip: entry.ip,
});
await this.getEntityManager().persist(row).flush();
}
}
+4
View File
@@ -1,4 +1,7 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { AuditLog } from '../../db/entities/AuditLog.entity';
import { Users } from '../../db/entities/Users.entity';
import { AuditService } from './audit.service';
/** Cross-cutting audit domain (Wave 2). No controller/MCP surface of its own —
@@ -9,6 +12,7 @@ import { AuditService } from './audit.service';
* transitively through each consumer's explicit import. Registered in
* AppModule. */
@Module({
imports: [MikroOrmModule.forFeature([AuditLog, Users])],
providers: [AuditService],
exports: [AuditService],
})
+18 -8
View File
@@ -1,5 +1,9 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { AuditLog } from '../../db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../db/repositories/AuditLog.repository';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { logInfo, logDebug, logError } from './audit-log.logger';
const ACTION_LABELS: Record<string, string> = {
@@ -66,13 +70,16 @@ function buildInfoSummary(action: string, details?: Record<string, unknown>): st
@Injectable()
export class AuditService {
constructor(private readonly dbs: DatabaseService) {}
constructor(
@InjectRepository(AuditLog) private readonly auditLog: AuditLogRepository,
@InjectRepository(Users) private readonly users: UsersRepository,
) {}
private async resolveUserEmail(userId: number | null): Promise<string> {
if (userId == null) return 'anonymous';
try {
const row = this.dbs.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId);
return row?.email || `uid:${userId}`;
const email = await this.users.getEmail(userId);
return email || `uid:${userId}`;
} catch { return `uid:${userId}`; }
}
@@ -87,10 +94,13 @@ export class AuditService {
}): Promise<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
);
await this.auditLog.insertEntry({
user_id: entry.userId,
action: entry.action,
resource: entry.resource ?? null,
details: detailsJson,
ip: entry.ip ?? null,
});
const email = await this.resolveUserEmail(entry.userId);
const label = ACTION_LABELS[entry.action] || entry.action;
+2 -1
View File
@@ -42,6 +42,7 @@ import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
let server: Server;
@@ -49,7 +50,7 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
let userId: number;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, AtlasModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, AtlasModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -57,6 +57,7 @@ import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Auth e2e (real auth guard + real service + real cookie service + temp SQLite)', () => {
let server: Server;
@@ -66,7 +67,7 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp
let userPassword: string;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, AuthModule] })
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, AuthModule] })
// The mailer is a provider since the notifications fold; overriding it is
// the DI-native replacement for the old services/notifications module mock.
.overrideProvider(MailerService)
+2 -1
View File
@@ -48,13 +48,14 @@ 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';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Backup e2e (real auth + admin guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, BackupModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, BackupModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -77,13 +77,14 @@ import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { RateLimitService } from '../../src/nest/common/rate-limit.service';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Collab e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, CollabModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, CollabModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -46,6 +46,7 @@ import { AddonsService } from '../../src/nest/addons/addons.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Collections e2e (real auth guard + real service + temp SQLite)', () => {
let server: Server;
@@ -55,7 +56,7 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
let tripId: number;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, CollectionsModule] })
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, CollectionsModule] })
.overrideProvider(AddonsService)
.useValue({ isAddonEnabled })
.compile();
+2 -1
View File
@@ -84,6 +84,7 @@ import { NextcloudDocumentProvider, OpencloudDocumentProvider } from '../../src/
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
/** A provider that answers plausibly but never opens a socket. */
function fakeProvider(id: string) {
@@ -137,7 +138,7 @@ describe('Document sync e2e (real guards + real services + temp SQLite)', () =>
[OpencloudDocumentProvider, 'opencloud'],
[SynologyDriveDocumentProvider, 'synologydrive'],
] as const;
let builder = Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, DocSyncModule] })
let builder = Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, DocSyncModule] })
.overrideProvider(AddonsService)
.useValue({ isAddonEnabled });
const fakes = providers.map(([, id]) => fakeProvider(id));
+2 -1
View File
@@ -82,13 +82,14 @@ import { FilesModule } from '../../src/nest/files/files.module';
import { PhotosModule } from '../../src/nest/photos/photos.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Files + photos e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, FilesModule, PhotosModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, FilesModule, PhotosModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -31,13 +31,14 @@ import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Maps e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, MapsModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, MapsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -68,6 +68,7 @@ import { MemoriesAccessService } from '../../src/nest/memories/memories-access.s
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
const BASE = '/api/integrations/memories';
const UNIFIED = `${BASE}/unified`;
@@ -79,7 +80,7 @@ describe('Memories e2e (real auth guard + temp SQLite)', () => {
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, MemoriesModule] })
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, MemoriesModule] })
.overrideProvider(UnifiedMemoriesService).useValue(unified)
.overrideProvider(ImmichService).useValue(immich)
.overrideProvider(SynologyService).useValue(synology)
+2 -1
View File
@@ -71,6 +71,7 @@ import { NtfyService } from '../../src/nest/notifications/transports/ntfy.servic
import { WebhookService } from '../../src/nest/notifications/transports/webhook.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
function seedNotification(recipientId: number, overrides: { is_read?: number } = {}): number {
const r = db.prepare(
@@ -86,7 +87,7 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => {
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, NotificationsModule],
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, NotificationsModule],
})
.overrideProvider(NotificationPreferencesService).useValue(prefs)
.overrideProvider(MailerService).useValue(mailer)
+2 -1
View File
@@ -57,13 +57,14 @@ import { AddonsService } from '../../src/nest/addons/addons.service';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('OAuth e2e (real guards + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, OauthModule] })
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, OauthModule] })
.overrideProvider(AddonsService)
.useValue({ isAddonEnabled })
.overrideProvider(OauthService)
+2 -1
View File
@@ -51,6 +51,7 @@ import { DatabaseModule } from '../../src/nest/database/database.module';
import { AuthService } from '../../src/nest/auth/auth.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('OIDC e2e (real cookie service)', () => {
let server: Server;
@@ -58,7 +59,7 @@ describe('OIDC e2e (real cookie service)', () => {
let consumeAuthCode: MockInstance;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, OidcModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, OidcModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
@@ -38,6 +38,7 @@ import { RateLimitService } from '../../src/nest/common/rate-limit.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
const BODY = { lat: 50.9, lng: 6.96, name: 'Museum Ludwig', placeId: 'way:12345' };
@@ -47,7 +48,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () =>
let maps: MapsService;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, PlaceEnrichmentModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, PlaceEnrichmentModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -58,6 +58,7 @@ vi.mock('../../src/config', () => ({ ENCRYPTION_KEY: 'e2e-storage-key', JWT_SECR
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Storage admin e2e (real auth + admin guard + managed guard + temp SQLite)', () => {
let server: Server;
@@ -67,7 +68,7 @@ describe('Storage admin e2e (real auth + admin guard + managed guard + temp SQLi
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, StorageModule],
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, StorageModule],
providers: [{ provide: APP_GUARD, useClass: ManagedGuard }],
}).compile();
const nest = moduleRef.createNestApplication();
+2 -1
View File
@@ -38,6 +38,7 @@ import { DatabaseModule } from '../../src/nest/database/database.module';
import { TagsModule } from '../../src/nest/tags/tags.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
function insertTag(userId: number, name: string, color = '#10b981'): number {
const res = db.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(userId, name, color);
@@ -49,7 +50,7 @@ describe('Tags e2e (real auth guard + temp SQLite)', () => {
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, TagsModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, TagsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -80,13 +80,14 @@ import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { broadcastToUser } from '../../src/websocket';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Vacay e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, VacayModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, RealtimeModule, VacayModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+8 -1
View File
@@ -40,9 +40,14 @@ import { OauthService } from '../../src/nest/oauth/oauth.service';
import { DatabaseService } from '../../src/nest/database/database.service';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { AuditService } from '../../src/nest/audit/audit.service';
import { createTestOrm } from '../helpers/test-orm';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../src/db/repositories/AuditLog.repository';
import { Users } from '../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../src/db/repositories/Users.repository';
const oauthDbs = new DatabaseService(testDb);
const oauthSvc = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(oauthDbs));
let oauthSvc: OauthService;
/** Mint a trekoa_ access token for the user via a fresh OAuth client. */
async function mintOauthToken(userId: number, audience: string | null, scopes: string[] = ['trips:read']): Promise<{ accessToken: string; clientId: string }> {
@@ -69,6 +74,8 @@ let app: Application;
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
const t = await createTestOrm(testDb);
oauthSvc = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository));
});
beforeEach(() => {
@@ -44,13 +44,18 @@ import { OauthService } from '../../src/nest/oauth/oauth.service';
import { DatabaseService } from '../../src/nest/database/database.service';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { AuditService } from '../../src/nest/audit/audit.service';
import { createTestOrm } from '../helpers/test-orm';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../src/db/repositories/AuditLog.repository';
import { Users } from '../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../src/db/repositories/Users.repository';
// The consent controller writes pending codes through the container instance;
// the SDK-mounted authorize path reads them back. The map is module-scoped in
// oauth.pending-codes.ts, so a hand-built service instance shares it — the
// same single-instance property the full auth-code loop below proves end-to-end.
const oauthDbs = new DatabaseService(testDb);
const containerSideOauth = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(oauthDbs));
let containerSideOauth: OauthService;
let nestApp: INestApplication;
let app: Application;
@@ -80,6 +85,8 @@ async function registerClient(redirectUri = 'https://client.example.com/cb', sco
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
const t = await createTestOrm(testDb);
containerSideOauth = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository));
});
beforeEach(() => {
+8 -1
View File
@@ -41,6 +41,11 @@ import { OauthService } from '../../src/nest/oauth/oauth.service';
import { DatabaseService } from '../../src/nest/database/database.service';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { AuditService } from '../../src/nest/audit/audit.service';
import { createTestOrm } from '../helpers/test-orm';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../src/db/repositories/AuditLog.repository';
import { Users } from '../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../src/db/repositories/Users.repository';
// In production the consent controller writes pending codes through the
// container instance and the SDK routes read them back through the same
@@ -48,7 +53,7 @@ import { AuditService } from '../../src/nest/audit/audit.service';
// does: through a service instance. The pending-code map is module-scoped in
// oauth.pending-codes.ts, so the routes under test see every code written here.
const oauthDbs = new DatabaseService(testDb);
const containerSideOauth = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(oauthDbs));
let containerSideOauth: OauthService;
// Arrow wrappers rather than `.bind`: with `strictBindCallApply: false` a bound
// alias is typed `any`, which hides a missing `await` on the now-async methods.
const createAuthCode = (...args: Parameters<OauthService['createAuthCode']>) => containerSideOauth.createAuthCode(...args);
@@ -78,6 +83,8 @@ function setMcpEnabled(enabled: boolean) {
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
const t = await createTestOrm(testDb);
containerSideOauth = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository));
});
beforeEach(() => {
@@ -59,6 +59,11 @@ import { PluginRpcHostFactory } from '../../../src/nest/plugins/host/plugin-rpc-
import { PluginRpcRegistry } from '../../../src/nest/plugins/host/rpc-kit/registry';
import type { PluginRpcRegistryService } from '../../../src/nest/plugins/host/rpc-kit/registry.service';
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
import { createTestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository';
import { Users } from '../../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
let codeRoot: string;
let dataRoot: string;
@@ -104,6 +109,9 @@ describe('plugin boot vs registry scan ordering', () => {
// own onModuleInit scan has run.
const registry = new PluginRpcRegistry();
const hostFactory = new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService);
const t = await createTestOrm(dbConn);
const auditLogRepo = t.repo(AuditLog) as AuditLogRepository;
const usersRepo = t.repo(Users) as UsersRepository;
mod = await Test.createTestingModule({
providers: [
@@ -112,7 +120,7 @@ describe('plugin boot vs registry scan ordering', () => {
// (if it had one) first. The boot reconcile must therefore not live there.
{
provide: PluginRuntimeService,
useFactory: () => new PluginRuntimeService(dbs, new AuditService(dbs), new AddonsService(dbs), userSettings, undefined, hostFactory),
useFactory: () => new PluginRuntimeService(dbs, new AuditService(auditLogRepo, usersRepo), new AddonsService(dbs), userSettings, undefined, hostFactory),
},
{
provide: 'REGISTRY_SCAN',
@@ -0,0 +1,64 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { AuditLog } from '../../../../src/db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../../../src/db/repositories/AuditLog.repository';
import { DB_TIMESTAMP_RE } from '../../../../src/db/types';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let auditLog: AuditLogRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
auditLog = t.repo(AuditLog) as AuditLogRepository;
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
describe('AuditLogRepository', () => {
it('AUDITREPO-001: insert writes the legacy column set, matching the SELECT * row exactly', async () => {
const { user } = createUser(testDb);
await auditLog.insertEntry({ user_id: user.id, action: 'trip.create', resource: 'trip', details: '{"title":"Rome"}', ip: '1.2.3.4' });
const row = testDb.prepare('SELECT * FROM audit_log').get() as { id: number; created_at: string };
expect(row).toStrictEqual({
id: row.id,
created_at: row.created_at,
user_id: user.id,
action: 'trip.create',
resource: 'trip',
details: '{"title":"Rome"}',
ip: '1.2.3.4',
});
});
it('AUDITREPO-002: created_at is left to the column default, as text, in the YYYY-MM-DD HH:MM:SS shape', async () => {
const { user } = createUser(testDb);
await auditLog.insertEntry({ user_id: user.id, action: 'user.login', resource: null, details: null, ip: null });
const stored = testDb.prepare('SELECT created_at, typeof(created_at) AS kind FROM audit_log').get() as { created_at: string; kind: string };
expect(stored.kind).toBe('text');
expect(stored.created_at).toMatch(DB_TIMESTAMP_RE);
});
it('AUDITREPO-003: a null user writes a null user_id twin, not a foreign-key value', async () => {
await auditLog.insertEntry({ user_id: null, action: 'user.login', resource: null, details: null, ip: null });
const row = testDb.prepare('SELECT user_id FROM audit_log').get() as { user_id: number | null };
expect(row.user_id).toBeNull();
});
it('AUDITREPO-004: null resource/details/ip are stored as NULL, not the string "null"', async () => {
const { user } = createUser(testDb);
await auditLog.insertEntry({ user_id: user.id, action: 'user.login', resource: null, details: null, ip: null });
const row = testDb.prepare('SELECT resource, details, ip FROM audit_log').get();
expect(row).toEqual({ resource: null, details: null, ip: null });
});
it('AUDITREPO-005: resolves without returning a value — no id or row is read back', async () => {
const { user } = createUser(testDb);
await expect(
auditLog.insertEntry({ user_id: user.id, action: 'user.login', resource: null, details: null, ip: null }),
).resolves.toBeUndefined();
});
});
+31 -32
View File
@@ -1,32 +1,15 @@
/**
* 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
* AUDIT-SVC-019 (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).
* introduced with the move; 008–014, 018, 019 cover writeAudit over a real
* in-memory SQLite DB through AuditLogRepository/UsersRepository, which the
* legacy suite never exercised). 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(),
@@ -36,27 +19,44 @@ vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
}));
import type { Request } from 'express';
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
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';
const svc = new AuditService(new DatabaseService(testDb));
const testDb = new Database(':memory:');
testDb.exec('PRAGMA journal_mode = WAL');
testDb.exec('PRAGMA foreign_keys = ON');
testDb.exec('PRAGMA busy_timeout = 5000');
beforeAll(() => {
let t: TestOrm;
let auditLogRepo: AuditLogRepository;
let svc: AuditService;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
t = await createTestOrm(testDb);
auditLogRepo = t.repo(AuditLog) as AuditLogRepository;
svc = new AuditService(auditLogRepo, t.repo(Users) as UsersRepository);
});
beforeEach(() => {
vi.clearAllMocks();
testDb.prepare('DELETE FROM audit_log').run();
testDb.prepare('DELETE FROM users').run();
t.clear();
});
afterAll(() => {
afterAll(async () => {
await t.close();
testDb.close();
});
@@ -111,7 +111,7 @@ describe('getClientIp', () => {
});
});
// ── writeAudit (real DB) ──────────────────────────────────────────────────────
// ── writeAudit (real DB, through the repositories) ────────────────────────────
function seedUser(id: number, email: string): void {
testDb.prepare(
@@ -180,10 +180,10 @@ describe('writeAudit', () => {
});
it('AUDIT-SVC-013: never throws — a failed insert reduces to a logError line', async () => {
testDb.prepare('ALTER TABLE audit_log RENAME TO audit_log_gone').run();
const spy = vi.spyOn(auditLogRepo, 'insertEntry').mockRejectedValueOnce(new Error('insert failed'));
await expect(svc.writeAudit({ userId: 1, action: 'user.login' })).resolves.not.toThrow();
expect(logError).toHaveBeenCalledWith(expect.stringMatching(/^Audit write failed: /));
testDb.prepare('ALTER TABLE audit_log_gone RENAME TO audit_log').run();
expect(logError).toHaveBeenCalledWith('Audit write failed: insert failed');
spy.mockRestore();
});
it('AUDIT-SVC-018: settings.api_keys_update names the changed keys and nothing else (#1939)', async () => {
@@ -212,4 +212,3 @@ describe('writeAudit', () => {
expect(logInfo).toHaveBeenLastCalledWith('a@b.c logged in ip=1.1.1.1');
});
});
+14 -3
View File
@@ -65,12 +65,19 @@ import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { getMcpSafeUrl } from '../../../src/app-config';
import { ADDON_IDS } from '../../../src/addons';
import { MAX_PENDING_CODES, sweepPendingCodes } from '../../../src/nest/oauth/oauth.pending-codes';
import { createTestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository';
import { Users } from '../../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
const dbs = new DatabaseService(testDb);
// Stubbed rather than real: every case drives the MCP gate through this one
// flag, exactly as the addons.bridge mock did before the fold.
const addonsStub = { isAddonEnabled } as unknown as AddonsService;
const svc = new OauthService(dbs, addonsStub, new AuditService(dbs));
let svc: OauthService;
let auditLogRepo: AuditLogRepository;
let usersRepo: UsersRepository;
// Legacy free-function names delegating to the service, so the moved cases below
// read exactly as they did before the fold. Arrow wrappers rather than `.bind`:
@@ -95,9 +102,13 @@ const saveConsent = (...args: Parameters<OauthService['saveConsent']>) => svc.sa
const getConsent = (...args: Parameters<OauthService['getConsent']>) => svc.getConsent(...args);
const isConsentSufficient = (...args: Parameters<OauthService['isConsentSufficient']>) => svc.isConsentSufficient(...args);
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
const t = await createTestOrm(testDb);
auditLogRepo = t.repo(AuditLog) as AuditLogRepository;
usersRepo = t.repo(Users) as UsersRepository;
svc = new OauthService(dbs, addonsStub, new AuditService(auditLogRepo, usersRepo));
});
beforeEach(() => {
@@ -1285,7 +1296,7 @@ describe('module-scoped OAuth state', () => {
codeChallengeMethod: 'S256',
})!;
const secondInstance = new OauthService(dbs, addonsStub, new AuditService(dbs));
const secondInstance = new OauthService(dbs, addonsStub, new AuditService(auditLogRepo, usersRepo));
expect(secondInstance.consumeAuthCode(code)?.userId).toBe(42);
});
});
@@ -28,6 +28,11 @@ import { runMigrations } from '../../../src/db/migrations';
import { createUser } from '../../helpers/factories';
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
import { createTestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository';
import { Users } from '../../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
/** The host-side settings reads, over the same connection the test seeded. */
const userSettings = () => new PluginUserSettingsService(new DatabaseService(dbConn));
@@ -126,7 +131,13 @@ 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), new AuditService(new DatabaseService(dbConn)), new AddonsService(new DatabaseService(dbConn)), userSettings());
const t = await createTestOrm(dbConn);
const rt = new PluginRuntimeService(
new DatabaseService(dbConn),
new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository),
new AddonsService(new DatabaseService(dbConn)),
userSettings(),
);
// 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,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
// Avoid any real DNS/network from the SSRF guard during saveSettings and the probe.
vi.mock('../../../src/utils/ssrfGuard', () => ({
@@ -12,15 +12,25 @@ import { AirtrailService } from '../../../src/nest/integrations/airtrail.service
import type { AirtrailClient } from '../../../src/nest/integrations/airtrail.client';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AuditService } from '../../../src/nest/audit/audit.service';
import { createTestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository';
import { Users } from '../../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
// The probe is the only call that would leave the process, so the client is a
// stub; the credential handling around it runs against the real row.
const listFlights = vi.fn();
const svc = new AirtrailService(
new DatabaseService(db),
new AuditService(new DatabaseService(db)),
{ listFlights } as unknown as AirtrailClient,
);
let svc: AirtrailService;
beforeAll(async () => {
const t = await createTestOrm(db);
svc = new AirtrailService(
new DatabaseService(db),
new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository),
{ listFlights } as unknown as AirtrailClient,
);
});
const MASK = '••••••••';
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeAll } from 'vitest';
// Avoid any real DNS/network from the SSRF guard during saveSettings.
vi.mock('../../../src/utils/ssrfGuard', () => ({
@@ -12,17 +12,30 @@ import { AirtrailService } from '../../../src/nest/integrations/airtrail.service
import { AirtrailClient } from '../../../src/nest/integrations/airtrail.client';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AuditService } from '../../../src/nest/database/../audit/audit.service';
import { createTestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository';
import { Users } from '../../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
// The free functions became methods with the airtrail fold; same SQL, same
// behaviour, one instance over the same db handle.
const svc = new AirtrailService(
new DatabaseService(db),
new AuditService(new DatabaseService(db)),
new AirtrailClient(),
);
const getConnectionSettings = (...args: Parameters<AirtrailService['getConnectionSettings']>) => svc.getConnectionSettings(...args);
const isAirtrailWriteEnabled = (...args: Parameters<AirtrailService['isAirtrailWriteEnabled']>) => svc.isAirtrailWriteEnabled(...args);
const saveSettings = (...args: Parameters<AirtrailService['saveSettings']>) => svc.saveSettings(...args);
let svc: AirtrailService;
let getConnectionSettings: (...args: Parameters<AirtrailService['getConnectionSettings']>) => ReturnType<AirtrailService['getConnectionSettings']>;
let isAirtrailWriteEnabled: (...args: Parameters<AirtrailService['isAirtrailWriteEnabled']>) => ReturnType<AirtrailService['isAirtrailWriteEnabled']>;
let saveSettings: (...args: Parameters<AirtrailService['saveSettings']>) => ReturnType<AirtrailService['saveSettings']>;
beforeAll(async () => {
const t = await createTestOrm(db);
svc = new AirtrailService(
new DatabaseService(db),
new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository),
new AirtrailClient(),
);
getConnectionSettings = (...args) => svc.getConnectionSettings(...args);
isAirtrailWriteEnabled = (...args) => svc.isAirtrailWriteEnabled(...args);
saveSettings = (...args) => svc.saveSettings(...args);
});
describe('airtrail writeback opt-in persistence (#1240)', () => {
it('defaults the writeback opt-in to off for a new user', async () => {