From c2da9d62f78a2d4b43afc1eb05ce27f0a63b92dd Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 22 Sep 2026 12:34:49 +0200 Subject: [PATCH] refactor(audit): audit log writes through AuditLogRepository --- .../db/repositories/AuditLog.repository.ts | 63 +++++++++++++++++- server/src/nest/audit/audit.module.ts | 4 ++ server/src/nest/audit/audit.service.ts | 26 +++++--- server/tests/e2e/atlas.e2e.test.ts | 3 +- server/tests/e2e/auth.e2e.test.ts | 3 +- server/tests/e2e/backup.e2e.test.ts | 3 +- server/tests/e2e/collab.e2e.test.ts | 3 +- server/tests/e2e/collections.e2e.test.ts | 3 +- server/tests/e2e/doc-sync.e2e.test.ts | 3 +- server/tests/e2e/files.e2e.test.ts | 3 +- server/tests/e2e/maps.e2e.test.ts | 3 +- server/tests/e2e/memories.e2e.test.ts | 3 +- server/tests/e2e/notifications.e2e.test.ts | 3 +- server/tests/e2e/oauth.e2e.test.ts | 3 +- server/tests/e2e/oidc.e2e.test.ts | 3 +- server/tests/e2e/place-enrichment.e2e.test.ts | 3 +- server/tests/e2e/storage-admin.e2e.test.ts | 3 +- server/tests/e2e/tags.e2e.test.ts | 3 +- server/tests/e2e/vacay.e2e.test.ts | 3 +- server/tests/integration/mcp.test.ts | 9 ++- .../tests/integration/oauth-authorize.test.ts | 9 ++- server/tests/integration/oauth.test.ts | 9 ++- .../plugins/boot-registry-order.test.ts | 10 ++- .../repositories/AuditLog.repository.test.ts | 64 +++++++++++++++++++ server/tests/unit/nest/audit.service.test.ts | 63 +++++++++--------- server/tests/unit/nest/oauth.service.test.ts | 17 ++++- .../unit/plugins/settings-isolation.test.ts | 13 +++- .../services/airtrailTestConnection.test.ts | 22 +++++-- .../unit/services/airtrailWriteback.test.ts | 31 ++++++--- 29 files changed, 308 insertions(+), 80 deletions(-) create mode 100644 server/tests/unit/db/repositories/AuditLog.repository.test.ts diff --git a/server/src/db/repositories/AuditLog.repository.ts b/server/src/db/repositories/AuditLog.repository.ts index 4e3929448..d0056ace5 100644 --- a/server/src/db/repositories/AuditLog.repository.ts +++ b/server/src/db/repositories/AuditLog.repository.ts @@ -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 {} +/** + * 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 = 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` (a `qb()` +// join to `Users` for `username`/`email`) and `count(): Promise`. +// Not implemented here — see plan3a-sql-inventory.md §1. + +export class AuditLogRepository extends EntityRepository { + /** + * 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>` + * (bypassing `create`/`persist`/`flush`), which is a `tsc`-verified + * incompatible override for a `Promise` 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 { + 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(); + } +} diff --git a/server/src/nest/audit/audit.module.ts b/server/src/nest/audit/audit.module.ts index c401144e9..355929b64 100644 --- a/server/src/nest/audit/audit.module.ts +++ b/server/src/nest/audit/audit.module.ts @@ -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], }) diff --git a/server/src/nest/audit/audit.service.ts b/server/src/nest/audit/audit.service.ts index b0b464b2d..378b2c24d 100644 --- a/server/src/nest/audit/audit.service.ts +++ b/server/src/nest/audit/audit.service.ts @@ -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 = { @@ -66,13 +70,16 @@ function buildInfoSummary(action: string, details?: Record): 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 { 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 { 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; diff --git a/server/tests/e2e/atlas.e2e.test.ts b/server/tests/e2e/atlas.e2e.test.ts index a5dddab22..2ad7ffad8 100644 --- a/server/tests/e2e/atlas.e2e.test.ts +++ b/server/tests/e2e/atlas.e2e.test.ts @@ -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()); diff --git a/server/tests/e2e/auth.e2e.test.ts b/server/tests/e2e/auth.e2e.test.ts index 0583a3e52..73f7b046a 100644 --- a/server/tests/e2e/auth.e2e.test.ts +++ b/server/tests/e2e/auth.e2e.test.ts @@ -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) diff --git a/server/tests/e2e/backup.e2e.test.ts b/server/tests/e2e/backup.e2e.test.ts index 2aba69d83..8602e4360 100644 --- a/server/tests/e2e/backup.e2e.test.ts +++ b/server/tests/e2e/backup.e2e.test.ts @@ -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>; 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()); diff --git a/server/tests/e2e/collab.e2e.test.ts b/server/tests/e2e/collab.e2e.test.ts index c9c2de0e4..f109c6757 100644 --- a/server/tests/e2e/collab.e2e.test.ts +++ b/server/tests/e2e/collab.e2e.test.ts @@ -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>; 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()); diff --git a/server/tests/e2e/collections.e2e.test.ts b/server/tests/e2e/collections.e2e.test.ts index cb86fa9fd..a32fa3c4f 100644 --- a/server/tests/e2e/collections.e2e.test.ts +++ b/server/tests/e2e/collections.e2e.test.ts @@ -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(); diff --git a/server/tests/e2e/doc-sync.e2e.test.ts b/server/tests/e2e/doc-sync.e2e.test.ts index 8caed6d49..488c725d7 100644 --- a/server/tests/e2e/doc-sync.e2e.test.ts +++ b/server/tests/e2e/doc-sync.e2e.test.ts @@ -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)); diff --git a/server/tests/e2e/files.e2e.test.ts b/server/tests/e2e/files.e2e.test.ts index edbbcbc2c..37484bb72 100644 --- a/server/tests/e2e/files.e2e.test.ts +++ b/server/tests/e2e/files.e2e.test.ts @@ -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>; 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()); diff --git a/server/tests/e2e/maps.e2e.test.ts b/server/tests/e2e/maps.e2e.test.ts index ce78f9c80..e34a9bc91 100644 --- a/server/tests/e2e/maps.e2e.test.ts +++ b/server/tests/e2e/maps.e2e.test.ts @@ -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>; 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()); diff --git a/server/tests/e2e/memories.e2e.test.ts b/server/tests/e2e/memories.e2e.test.ts index dea867fa8..a4fe14f11 100644 --- a/server/tests/e2e/memories.e2e.test.ts +++ b/server/tests/e2e/memories.e2e.test.ts @@ -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>; 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) diff --git a/server/tests/e2e/notifications.e2e.test.ts b/server/tests/e2e/notifications.e2e.test.ts index 76828c474..a3714bd08 100644 --- a/server/tests/e2e/notifications.e2e.test.ts +++ b/server/tests/e2e/notifications.e2e.test.ts @@ -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) diff --git a/server/tests/e2e/oauth.e2e.test.ts b/server/tests/e2e/oauth.e2e.test.ts index 1feffb007..721b3d79c 100644 --- a/server/tests/e2e/oauth.e2e.test.ts +++ b/server/tests/e2e/oauth.e2e.test.ts @@ -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>; 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) diff --git a/server/tests/e2e/oidc.e2e.test.ts b/server/tests/e2e/oidc.e2e.test.ts index 9af1ec102..a99dd1a14 100644 --- a/server/tests/e2e/oidc.e2e.test.ts +++ b/server/tests/e2e/oidc.e2e.test.ts @@ -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()); diff --git a/server/tests/e2e/place-enrichment.e2e.test.ts b/server/tests/e2e/place-enrichment.e2e.test.ts index fd35444d3..ae2420ca9 100644 --- a/server/tests/e2e/place-enrichment.e2e.test.ts +++ b/server/tests/e2e/place-enrichment.e2e.test.ts @@ -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()); diff --git a/server/tests/e2e/storage-admin.e2e.test.ts b/server/tests/e2e/storage-admin.e2e.test.ts index 26b4b9e26..1f6c4a11f 100644 --- a/server/tests/e2e/storage-admin.e2e.test.ts +++ b/server/tests/e2e/storage-admin.e2e.test.ts @@ -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(); diff --git a/server/tests/e2e/tags.e2e.test.ts b/server/tests/e2e/tags.e2e.test.ts index 0f59ea507..1dc1fb458 100644 --- a/server/tests/e2e/tags.e2e.test.ts +++ b/server/tests/e2e/tags.e2e.test.ts @@ -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>; 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()); diff --git a/server/tests/e2e/vacay.e2e.test.ts b/server/tests/e2e/vacay.e2e.test.ts index cc63222ba..43d391b0c 100644 --- a/server/tests/e2e/vacay.e2e.test.ts +++ b/server/tests/e2e/vacay.e2e.test.ts @@ -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>; 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()); diff --git a/server/tests/integration/mcp.test.ts b/server/tests/integration/mcp.test.ts index 4b673593f..816e4c45d 100644 --- a/server/tests/integration/mcp.test.ts +++ b/server/tests/integration/mcp.test.ts @@ -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(() => { diff --git a/server/tests/integration/oauth-authorize.test.ts b/server/tests/integration/oauth-authorize.test.ts index 99a989eaf..a3b6a7027 100644 --- a/server/tests/integration/oauth-authorize.test.ts +++ b/server/tests/integration/oauth-authorize.test.ts @@ -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(() => { diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index 8ede030fb..cc99f9cd0 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -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) => 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(() => { diff --git a/server/tests/integration/plugins/boot-registry-order.test.ts b/server/tests/integration/plugins/boot-registry-order.test.ts index 23fbb47f8..dd68b1f4e 100644 --- a/server/tests/integration/plugins/boot-registry-order.test.ts +++ b/server/tests/integration/plugins/boot-registry-order.test.ts @@ -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', diff --git a/server/tests/unit/db/repositories/AuditLog.repository.test.ts b/server/tests/unit/db/repositories/AuditLog.repository.test.ts new file mode 100644 index 000000000..dfb0795c4 --- /dev/null +++ b/server/tests/unit/db/repositories/AuditLog.repository.test.ts @@ -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(); + }); +}); diff --git a/server/tests/unit/nest/audit.service.test.ts b/server/tests/unit/nest/audit.service.test.ts index 43354c394..0788e484d 100644 --- a/server/tests/unit/nest/audit.service.test.ts +++ b/server/tests/unit/nest/audit.service.test.ts @@ -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'); }); }); - diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts index e79da2519..9525cce0d 100644 --- a/server/tests/unit/nest/oauth.service.test.ts +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -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) => svc.sa const getConsent = (...args: Parameters) => svc.getConsent(...args); const isConsentSufficient = (...args: Parameters) => 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); }); }); diff --git a/server/tests/unit/plugins/settings-isolation.test.ts b/server/tests/unit/plugins/settings-isolation.test.ts index 682d0e44e..20aa211ad 100644 --- a/server/tests/unit/plugins/settings-isolation.test.ts +++ b/server/tests/unit/plugins/settings-isolation.test.ts @@ -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 } }).supervisor.running.set('loud', { id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']), diff --git a/server/tests/unit/services/airtrailTestConnection.test.ts b/server/tests/unit/services/airtrailTestConnection.test.ts index 22ec47cc6..6063b675f 100644 --- a/server/tests/unit/services/airtrailTestConnection.test.ts +++ b/server/tests/unit/services/airtrailTestConnection.test.ts @@ -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 = '••••••••'; diff --git a/server/tests/unit/services/airtrailWriteback.test.ts b/server/tests/unit/services/airtrailWriteback.test.ts index 2c26adf74..e09449007 100644 --- a/server/tests/unit/services/airtrailWriteback.test.ts +++ b/server/tests/unit/services/airtrailWriteback.test.ts @@ -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) => svc.getConnectionSettings(...args); -const isAirtrailWriteEnabled = (...args: Parameters) => svc.isAirtrailWriteEnabled(...args); -const saveSettings = (...args: Parameters) => svc.saveSettings(...args); +let svc: AirtrailService; +let getConnectionSettings: (...args: Parameters) => ReturnType; +let isAirtrailWriteEnabled: (...args: Parameters) => ReturnType; +let saveSettings: (...args: Parameters) => ReturnType; + +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 () => {