diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts index 32a94a5f5..f4742ef4c 100644 --- a/server/src/nest/addons/addons.service.ts +++ b/server/src/nest/addons/addons.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; import type { Addon } from '../../types'; import { getBagTracking, getCollabFeatures } from '../../services/adminService'; import { getPhotoProviderConfig } from '../../services/memories/helpersService'; @@ -12,11 +12,17 @@ import { getPhotoProviderConfig } from '../../services/memories/helpersService'; */ @Injectable() export class AddonsService { + constructor(private readonly dbs: DatabaseService) {} + + private get db() { + return this.dbs.connection; + } + list() { - const addons = db + const addons = this.db .prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order') .all() as Pick[]; - const providers = db + const providers = this.db .prepare( `SELECT id, name, icon, enabled, sort_order FROM photo_providers @@ -24,7 +30,7 @@ export class AddonsService { ORDER BY sort_order, id`, ) .all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>; - const fields = db + const fields = this.db .prepare( `SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order FROM photo_provider_fields diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index 8f060f0e3..a11f2eb47 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -8,7 +8,7 @@ import { createBudgetItem, freezeForeignRate } from '../../services/budgetServic import { isAddonEnabled } from '../../services/adminService'; import { ADDON_IDS } from '../../addons'; import { searchNominatim } from '../../services/mapsService'; -import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; import type { User } from '../../types'; import { KitineraryExtractorService } from './kitinerary-extractor.service'; import { LlmParseService } from '../llm-parse/llm-parse.service'; @@ -17,25 +17,30 @@ import { typeToCostCategory } from '@trek/shared'; import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode, BookingImportFileReport, Reservation } from '@trek/shared'; import type { ParsedBookingItem, KiReservation } from './kitinerary.types'; -function resolveDayId(tripId: string, iso: string | null | undefined): number | null { - if (!iso) return null; - const date = iso.slice(0, 10); - if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null; - const exact = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined; - if (exact) return exact.id; - // Clamp to the nearest trip day so an out-of-range / unmatched check-in still - // resolves and the accommodation row is inserted. - const nearest = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1').get(tripId, date) as { id: number } | undefined; - return nearest?.id ?? null; -} - @Injectable() export class BookingImportService { constructor( private readonly extractor: KitineraryExtractorService, private readonly llmParse: LlmParseService, + private readonly dbs: DatabaseService, ) {} + private get db() { + return this.dbs.connection; + } + + private resolveDayId(tripId: string, iso: string | null | undefined): number | null { + if (!iso) return null; + const date = iso.slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null; + const exact = this.db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined; + if (exact) return exact.id; + // Clamp to the nearest trip day so an out-of-range / unmatched check-in still + // resolves and the accommodation row is inserted. + const nearest = this.db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1').get(tripId, date) as { id: number } | undefined; + return nearest?.id ?? null; + } + isAvailable(): boolean { return this.extractor.isAvailable(); } @@ -202,8 +207,8 @@ export class BookingImportService { // the accommodation row is actually inserted (createReservation gates on them). let createAccommodation: { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_out?: string; confirmation?: string } | undefined; if (item.type === 'hotel' && _accommodation) { - const startDayId = resolveDayId(tripId, _accommodation.check_in); - const endDayId = resolveDayId(tripId, _accommodation.check_out); + const startDayId = this.resolveDayId(tripId, _accommodation.check_in); + const endDayId = this.resolveDayId(tripId, _accommodation.check_out); createAccommodation = { place_id: placeId, start_day_id: startDayId ?? undefined, diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 57c8ee870..560bd5d08 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; import { broadcast } from '../../websocket'; import { checkPermission } from '../../services/permissions'; import type { User } from '../../types'; @@ -15,6 +15,12 @@ type Trip = NonNullable>; */ @Injectable() export class BudgetService { + constructor(private readonly dbs: DatabaseService) {} + + private get db() { + return this.dbs.connection; + } + verifyTripAccess(tripId: string, userId: number) { return svc.verifyTripAccess(tripId, userId); } @@ -106,14 +112,14 @@ export class BudgetService { */ syncReservationPrice(tripId: string, reservationId: number, totalPrice: number, socketId: string | undefined): void { try { - const reservation = db.prepare( + const reservation = this.db.prepare( 'SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?', ).get(reservationId, tripId) as { id: number; metadata: string | null } | undefined; if (!reservation) return; const meta = reservation.metadata ? JSON.parse(reservation.metadata) : {}; meta.price = String(totalPrice); - db.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), reservation.id); - const updatedRes = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservation.id); + this.db.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), reservation.id); + const updatedRes = this.db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservation.id); broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, socketId); } catch (err) { console.error('[budget] Failed to sync price to reservation:', err); diff --git a/server/src/nest/collab/collab.service.ts b/server/src/nest/collab/collab.service.ts index 6f05ff7da..e36a800d2 100644 --- a/server/src/nest/collab/collab.service.ts +++ b/server/src/nest/collab/collab.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; import { broadcast } from '../../websocket'; import { checkPermission } from '../../services/permissions'; import type { User } from '../../types'; @@ -14,6 +14,12 @@ type Trip = NonNullable>; */ @Injectable() export class CollabService { + constructor(private readonly dbs: DatabaseService) {} + + private get db() { + return this.dbs.connection; + } + verifyTripAccess(tripId: string, userId: number) { return svc.verifyTripAccess(tripId, userId); } @@ -54,7 +60,7 @@ export class CollabService { /** Fire-and-forget collab notification (mirrors the route's dynamic import). */ notifyCollab(tripId: string, actor: User, preview?: string): void { import('../../services/notificationService').then(({ send }) => { - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; + const tripInfo = this.db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; const params: Record = { trip: tripInfo?.title || 'Untitled', actor: actor.email, tripId: String(tripId) }; if (preview !== undefined) params.preview = preview; send({ event: 'collab_message', actorId: actor.id, scope: 'trip', targetId: Number(tripId), params }).catch(() => {}); diff --git a/server/src/nest/feeds/feeds.service.ts b/server/src/nest/feeds/feeds.service.ts index d0c48f286..b1a41e7a4 100644 --- a/server/src/nest/feeds/feeds.service.ts +++ b/server/src/nest/feeds/feeds.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'crypto'; -import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; import { exportICS } from '../../services/tripService'; const ninetyDaysAgo = () => { @@ -15,10 +15,16 @@ function feedUrl(token: string, scope: 'trip' | 'user', base: string): string { @Injectable() export class FeedsService { + constructor(private readonly dbs: DatabaseService) {} + + private get db() { + return this.dbs.connection; + } + // ── Trip feed token ───────────────────────────────────────────────────── private tripTokenRow(tripId: string, userId: number) { - return db + return this.db .prepare( 'SELECT feed_token FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))', ) @@ -35,26 +41,26 @@ export class FeedsService { const row = this.tripTokenRow(tripId, userId); if (row?.feed_token) return { feed_url: feedUrl(row.feed_token, 'trip', base) }; const token = randomUUID(); - db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId); + this.db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId); return { feed_url: feedUrl(token, 'trip', base) }; } /** Rotate: always issue a fresh token, invalidating the previous URL. */ rotateTripToken(tripId: string, base: string): { feed_url: string } { const token = randomUUID(); - db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId); + this.db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId); return { feed_url: feedUrl(token, 'trip', base) }; } /** Disable: clear the token so the public URL stops resolving. */ disableTripToken(tripId: string): void { - db.prepare('UPDATE trips SET feed_token = NULL WHERE id = ?').run(tripId); + this.db.prepare('UPDATE trips SET feed_token = NULL WHERE id = ?').run(tripId); } // ── User (all-trips) feed token ────────────────────────────────────────── getUserToken(userId: number, base: string): { feed_url: string | null } { - const row = db.prepare('SELECT feed_token FROM users WHERE id = ?').get(userId) as + const row = this.db.prepare('SELECT feed_token FROM users WHERE id = ?').get(userId) as | { feed_token: string | null } | undefined; return { feed_url: row?.feed_token ? feedUrl(row.feed_token, 'user', base) : null }; @@ -64,24 +70,24 @@ export class FeedsService { const existing = this.getUserToken(userId, base); if (existing.feed_url) return { feed_url: existing.feed_url }; const token = randomUUID(); - db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId); + this.db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId); return { feed_url: feedUrl(token, 'user', base) }; } rotateUserToken(userId: number, base: string): { feed_url: string } { const token = randomUUID(); - db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId); + this.db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId); return { feed_url: feedUrl(token, 'user', base) }; } disableUserToken(userId: number): void { - db.prepare('UPDATE users SET feed_token = NULL WHERE id = ?').run(userId); + this.db.prepare('UPDATE users SET feed_token = NULL WHERE id = ?').run(userId); } // ── ICS generation ─────────────────────────────────────────────────────── buildTripIcs(token: string): { ics: string; filename: string } | null { - const row = db.prepare('SELECT id FROM trips WHERE feed_token = ?').get(token) as + const row = this.db.prepare('SELECT id FROM trips WHERE feed_token = ?').get(token) as | { id: number } | undefined; if (!row) return null; @@ -101,7 +107,7 @@ export class FeedsService { } buildUserIcs(token: string): { ics: string; calName: string } | null { - const user = db.prepare('SELECT id, username FROM users WHERE feed_token = ?').get(token) as + const user = this.db.prepare('SELECT id, username FROM users WHERE feed_token = ?').get(token) as | { id: number; username: string } | undefined; if (!user) return null; @@ -110,7 +116,7 @@ export class FeedsService { // "All Trips" means every trip the user can open — trips they own AND trips shared with // them as a member — mirroring the single-trip feed's access (tripTokenRow/assertAccess). // A membership WHERE on trips selects each row once, so owned + member trips don't dupe. - const trips = db + const trips = this.db .prepare( `SELECT id FROM trips WHERE (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?)) diff --git a/server/src/nest/journey/journey.service.ts b/server/src/nest/journey/journey.service.ts index ac35c779e..60ce6287e 100644 --- a/server/src/nest/journey/journey.service.ts +++ b/server/src/nest/journey/journey.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; import * as svc from '../../services/journeyService'; import * as share from '../../services/journeyShareService'; import { uploadToImmich, streamImmichAsset } from '../../services/memories/immichService'; @@ -16,6 +16,12 @@ import type { Response } from 'express'; */ @Injectable() export class JourneyService { + constructor(private readonly dbs: DatabaseService) {} + + private get db() { + return this.dbs.connection; + } + journeyAddonEnabled(): boolean { return isAddonEnabled(ADDON_IDS.JOURNEY); } @@ -73,7 +79,7 @@ export class JourneyService { // Immich mirror (only when the user opted in via integration settings) immichAutoUploadEnabled(userId: number): boolean { - const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined; + const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined; return !!prefs?.immich_auto_upload; } uploadToImmich(userId: number, relativePath: string, originalName: string) { return uploadToImmich(userId, relativePath, originalName); } diff --git a/server/src/nest/packing/packing.service.ts b/server/src/nest/packing/packing.service.ts index 580acda43..f013dde07 100644 --- a/server/src/nest/packing/packing.service.ts +++ b/server/src/nest/packing/packing.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; import { broadcast } from '../../websocket'; import { checkPermission } from '../../services/permissions'; import type { User } from '../../types'; @@ -17,6 +17,12 @@ type Trip = NonNullable>; */ @Injectable() export class PackingService { + constructor(private readonly dbs: DatabaseService) {} + + private get db() { + return this.dbs.connection; + } + verifyTripAccess(tripId: string, userId: number) { return svc.verifyTripAccess(tripId, userId); } @@ -63,7 +69,7 @@ export class PackingService { /** Reads an item's current privacy fields (#858) before an update, so the * controller can detect a public↔private transition and route the broadcast. */ getItemPrivacy(tripId: string, id: string): PrivacyFields | undefined { - return db.prepare('SELECT is_private, owner_id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId) as PrivacyFields | undefined; + return this.db.prepare('SELECT is_private, owner_id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId) as PrivacyFields | undefined; } createItem(tripId: string, data: Parameters[1], ownerId?: number) { @@ -146,7 +152,7 @@ export class PackingService { notifyTagged(tripId: string, actor: User, category: string, userIds: unknown): void { if (!Array.isArray(userIds) || userIds.length === 0) return; import('../../services/notificationService').then(({ send }) => { - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; + const tripInfo = this.db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; send({ event: 'packing_tagged', actorId: actor.id, diff --git a/server/src/nest/plugins/host/create-rpc-host.ts b/server/src/nest/plugins/host/create-rpc-host.ts index 6d15c81dc..3ca509276 100644 --- a/server/src/nest/plugins/host/create-rpc-host.ts +++ b/server/src/nest/plugins/host/create-rpc-host.ts @@ -1,5 +1,6 @@ import { readEnv } from '../../../app-config'; import { db, canAccessTrip } from '../../../db/database'; +import { DatabaseService } from '../../database/database.service'; import { broadcast, broadcastToUser } from '../../../websocket'; import { listBudgetItems } from '../../../services/budgetService'; import { listItems as listPackingItemsSvc, createItem as createPackingItemSvc, updateItem as updatePackingItemSvc, deleteItem as deletePackingItemSvc, listBags, createBag as createBagSvc, updateBag as updateBagSvc, deleteBag as deleteBagSvc, setBagMembers } from '../../../services/packingService'; @@ -56,9 +57,11 @@ function canEditTripAs(action: string, tripId: number, userId: number): boolean } // Reused for costs.create so a plugin write frozen-FX and members/payers logic -// matches a normal web-app budget write exactly (it has no injected deps). -const budgetSvc = new BudgetService(); -const reservationsSvc = new ReservationsService(); +// matches a normal web-app budget write exactly. This factory file runs outside +// the Nest container, so the shared connection is handed over manually. +const dbs = new DatabaseService(db); +const budgetSvc = new BudgetService(dbs); +const reservationsSvc = new ReservationsService(dbs); // The booking notification the REST controller sends after a create/update/delete // is fire-and-forget, so it never blocks the plugin write. function notifyBooking(actingUserId: number, tripId: number, booking: string, type: string): void { @@ -424,7 +427,7 @@ export function createRealRpcHost(id: string, granted: ReadonlySet, rout // The acting user's own decrypted value for one of this plugin's user-scope settings. getUserSetting: (pluginId, userId, key) => readUserSettingDecrypted(pluginId, userId, key), // A short-lived OAuth access token for the acting user (host-brokered; refreshes). - getOAuthToken: (pluginId, userId) => new PluginOAuthService().getAccessToken(pluginId, userId, Date.now()), + getOAuthToken: (pluginId, userId) => new PluginOAuthService(dbs).getAccessToken(pluginId, userId, Date.now()), // Persistent scheduler (jobs:run). Caps bound the abuse surface: a plugin can't // hoard timers, name-bomb, ship a huge payload, or busy-loop a recurring task. schedulerSet: (name, dueAt, everyMs, payload) => { diff --git a/server/src/nest/plugins/plugin-oauth.service.ts b/server/src/nest/plugins/plugin-oauth.service.ts index 006c4f38a..dafad14f6 100644 --- a/server/src/nest/plugins/plugin-oauth.service.ts +++ b/server/src/nest/plugins/plugin-oauth.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import crypto from 'node:crypto'; -import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; import { encrypt_api_key, decrypt_api_key } from '../../services/apiKeyCrypto'; import { getAppUrl } from '../../services/notifications'; import { isPrivateIp } from './install/safe-fetch'; @@ -65,10 +65,16 @@ function assertSafeHttps(urlStr: string, what: string): URL { @Injectable() export class PluginOAuthService { + constructor(private readonly dbs: DatabaseService) {} + + private get db() { + return this.dbs.connection; + } + /** The plugin's decrypted OAuth provider config from its INSTANCE settings, or null * when any required piece is missing/blank. */ providerConfig(pluginId: string): OAuthProviderConfig | null { - const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(pluginId) as { config: string } | undefined; + const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(pluginId) as { config: string } | undefined; if (!row) return null; let cfg: Record; try { @@ -92,7 +98,7 @@ export class PluginOAuthService { /** Whether the acting user has a stored token for this plugin. */ status(pluginId: string, userId: number): { configured: boolean; connected: boolean } { const configured = this.providerConfig(pluginId) !== null; - const tok = db.prepare('SELECT 1 FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ? AND access_token IS NOT NULL').get(pluginId, userId); + const tok = this.db.prepare('SELECT 1 FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ? AND access_token IS NOT NULL').get(pluginId, userId); return { configured, connected: !!tok }; } @@ -108,8 +114,8 @@ export class PluginOAuthService { const state = b64url(crypto.randomBytes(24)); // Drop this user's stale states for the plugin, then store the fresh one. - db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId); - db.prepare('INSERT INTO plugin_oauth_state (state, plugin_id, user_id, verifier, created_at) VALUES (?, ?, ?, ?, ?)').run( + this.db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId); + this.db.prepare('INSERT INTO plugin_oauth_state (state, plugin_id, user_id, verifier, created_at) VALUES (?, ?, ?, ?, ?)').run( state, pluginId, userId, @@ -129,16 +135,16 @@ export class PluginOAuthService { /** Complete the callback: verify state, exchange the code, store the tokens. */ async completeCallback(pluginId: string, userId: number, code: string, state: string, nowMs: number): Promise { - const row = db.prepare('SELECT verifier, user_id, created_at FROM plugin_oauth_state WHERE state = ? AND plugin_id = ?').get(state, pluginId) as + const row = this.db.prepare('SELECT verifier, user_id, created_at FROM plugin_oauth_state WHERE state = ? AND plugin_id = ?').get(state, pluginId) as | { verifier: string; user_id: number; created_at: number } | undefined; // State must exist, belong to THIS user, and be fresh — this binds the callback to // the connect request and blocks CSRF / a replayed/foreign state. if (!row || row.user_id !== userId || nowMs - row.created_at > STATE_TTL_MS) { - db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state); + this.db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state); throw new Error('invalid or expired OAuth state'); } - db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state); // single-use + this.db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state); // single-use const cfg = this.providerConfig(pluginId); if (!cfg) throw new Error('OAuth is not configured for this plugin'); @@ -155,7 +161,7 @@ export class PluginOAuthService { /** A valid access token for the acting user, refreshing it if it is expiring. Null when * the user hasn't connected. The plugin never receives the refresh token. */ async getAccessToken(pluginId: string, userId: number, nowMs: number): Promise { - const row = db.prepare('SELECT access_token, refresh_token, expires_at FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as + const row = this.db.prepare('SELECT access_token, refresh_token, expires_at FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as | { access_token: string | null; refresh_token: string | null; expires_at: number | null } | undefined; if (!row || !row.access_token) return null; @@ -177,8 +183,8 @@ export class PluginOAuthService { } disconnect(pluginId: string, userId: number): void { - db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId); - db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId); + this.db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId); + this.db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId); } // --- internals --- @@ -209,7 +215,7 @@ export class PluginOAuthService { private storeToken(pluginId: string, userId: number, token: { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string }, nowMs: number): void { if (!token.access_token) throw new Error('token endpoint returned no access_token'); const expiresAt = token.expires_in ? nowMs + token.expires_in * 1000 : null; - db.prepare( + this.db.prepare( `INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, refresh_token, expires_at, scope, updated_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(plugin_id, user_id) DO UPDATE SET diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 86951d24e..0268e1470 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -1,6 +1,7 @@ import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common'; import semver from 'semver'; import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; import { pluginsEnabled } from './kill-switch'; import { setPluginEventSink } from '../../plugin-event-sink'; import { setUserDeletedSink } from '../../plugin-user-lifecycle'; @@ -133,18 +134,18 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { private readonly supervisor = new PluginSupervisor((id, granted) => createRealRpcHost(id, granted, this), { // Both hooks run from child lifecycle EventEmitter callbacks (exit / stderr 'data'), // so a throw here becomes an uncaughtException that has no host-side handler. During a - // restore the core DB is briefly CLOSED (closeDb → the db proxy throws on access), so a + // restore the core DB is briefly CLOSED (closeDb → the this.db proxy throws on access), so a // status/log write in that window would otherwise take the whole process down mid- // restore. Swallow any DB error — a missed status row / log line is never worth a crash. onStatus: (id, status, error) => { try { - db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, error ?? null, id); + this.db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, error ?? null, id); } catch { /* DB unavailable (e.g. mid-restore) — a status write must never crash the host */ } }, onLog: (id, level, msg) => { if (level !== 'error' && level !== 'warn') return; try { - db.prepare('INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)').run(id, level, msg); + this.db.prepare('INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)').run(id, level, msg); // Retention: a crash-looping plugin emits a stderr line per restart, so an // uncapped table grows without bound in the shared trek.db. Keep only the // most recent LOG_RETENTION rows per plugin (the admin view shows 200). @@ -162,9 +163,17 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // Coalesces overlapping erasure drains (the sweep and enqueue both trigger one). private drainInFlight: Promise | null = null; - // Optional at the type level so tests can `new PluginRuntimeService()` without a - // registry; Nest always injects the real one (the provider is in the module). - constructor(private readonly registry?: PluginRegistryService) {} + // The registry stays optional at the type level so tests can construct the + // service without one; Nest always injects the real one (the provider is in + // the module). + constructor( + private readonly dbs: DatabaseService, + private readonly registry?: PluginRegistryService, + ) {} + + private get db() { + return this.dbs.connection; + } onModuleInit(): void { if (!pluginsEnabled()) return; @@ -199,7 +208,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // boot must NEVER block app init, even in a context without plugin tables // (e.g. a slimmed-down test app that only imports AdminModule). try { - discoverPlugins(db); + discoverPlugins(this.db); const installed = this.installedDepRows(); const enabledIds = [...installed.values()].filter((r) => r.enabled).map((r) => r.id); let order: string[]; @@ -246,13 +255,13 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { if (active.length === 0) return; const now = Date.now(); const ph = active.map(() => '?').join(','); - const due = db + const due = this.db .prepare(`SELECT id, plugin_id, name, payload, every_ms FROM plugin_scheduled_tasks WHERE due_at <= ? AND plugin_id IN (${ph}) ORDER BY due_at LIMIT 200`) .all(now, ...active) as Array<{ id: number; plugin_id: string; name: string; payload: string; every_ms: number | null }>; for (const t of due) { if (!this.supervisor.isActive(t.plugin_id)) continue; // leave for a later sweep - if (t.every_ms) db.prepare('UPDATE plugin_scheduled_tasks SET due_at = ? WHERE id = ?').run(now + t.every_ms, t.id); - else db.prepare('DELETE FROM plugin_scheduled_tasks WHERE id = ?').run(t.id); + if (t.every_ms) this.db.prepare('UPDATE plugin_scheduled_tasks SET due_at = ? WHERE id = ?').run(now + t.every_ms, t.id); + else this.db.prepare('DELETE FROM plugin_scheduled_tasks WHERE id = ?').run(t.id); let payload: unknown = null; try { payload = JSON.parse(t.payload); } catch { /* corrupt payload -> null */ } this.supervisor.deliverScheduled(t.plugin_id, t.name, payload); @@ -268,8 +277,8 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { * throws — a bookkeeping error must not fail the account deletion that triggered it. */ private enqueueUserErasure(userId: number): void { try { - const rows = db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>; - const insert = db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)'); + const rows = this.db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>; + const insert = this.db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)'); for (const r of rows) { let perms: unknown; try { perms = JSON.parse(r.permissions ?? '[]'); } catch { perms = []; } @@ -304,12 +313,12 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // honour the erasure (see uninstall()); reaping on registry-absence alone would wipe // exactly those preserved obligations. A deleteData=true uninstall already clears the // rows itself, so this only ever needs to catch a truly orphaned data dir. - const orphans = db + const orphans = this.db .prepare('SELECT DISTINCT plugin_id FROM plugin_user_erasure_queue WHERE plugin_id NOT IN (SELECT id FROM plugins)') .all() as Array<{ plugin_id: string }>; for (const { plugin_id } of orphans) { if (!fs.existsSync(pluginDataDir(plugin_id))) { - db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(plugin_id); + this.db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(plugin_id); } } // Only ACTIVE plugins can be delivered to; scope the window to them so a backlog @@ -317,13 +326,13 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { const active = this.supervisor.activeIds(); if (active.length === 0) return; const ph = active.map(() => '?').join(','); - const pending = db + const pending = this.db .prepare(`SELECT id, plugin_id, user_id FROM plugin_user_erasure_queue WHERE plugin_id IN (${ph}) ORDER BY id LIMIT 200`) .all(...active) as Array<{ id: number; plugin_id: string; user_id: number }>; for (const row of pending) { if (!this.supervisor.isActive(row.plugin_id)) continue; // retry after it reactivates const done = await this.supervisor.deliverUserErasure(row.plugin_id, row.user_id); - if (done) db.prepare('DELETE FROM plugin_user_erasure_queue WHERE id = ?').run(row.id); + if (done) this.db.prepare('DELETE FROM plugin_user_erasure_queue WHERE id = ?').run(row.id); } } catch { /* a drain pass must never break the runtime */ @@ -336,7 +345,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { async exportUserData(userId: number): Promise; oauthConnected?: boolean }>> { const out: Array<{ pluginId: string; data?: unknown; pending?: boolean; settings?: Record; oauthConnected?: boolean }> = []; if (!pluginsEnabled()) return out; - const rows = db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>; + const rows = this.db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>; for (const r of rows) { if (this.supervisor.isActive(r.id)) { const res = await this.supervisor.collectUserExport(r.id, userId); @@ -356,7 +365,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // Fold in the host-side per-user data TREK stores itself (what erasePluginUserData // deletes) so an access request isn't asymmetric with erasure: the user's plugin // settings (secret fields masked) and which plugins they OAuth-linked. Raw tokens - // are never exported. This is supplementary to each plugin's own-db export above, so + // are never exported. This is supplementary to each plugin's own-this.db export above, so // an unexpected failure here must not drop that primary data — it's best-effort. try { const byId = new Map(out.map((o) => [o.pluginId, o])); @@ -366,12 +375,12 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { return e; }; const secretKeys = new Map>(); - for (const f of db.prepare("SELECT plugin_id, field_key FROM plugin_settings_fields WHERE scope = 'user' AND secret = 1").all() as Array<{ plugin_id: string; field_key: string }>) { + for (const f of this.db.prepare("SELECT plugin_id, field_key FROM plugin_settings_fields WHERE scope = 'user' AND secret = 1").all() as Array<{ plugin_id: string; field_key: string }>) { let s = secretKeys.get(f.plugin_id); if (!s) { s = new Set(); secretKeys.set(f.plugin_id, s); } s.add(f.field_key); } - for (const c of db.prepare('SELECT plugin_id, config FROM plugin_user_config WHERE user_id = ?').all(userId) as Array<{ plugin_id: string; config: string }>) { + for (const c of this.db.prepare('SELECT plugin_id, config FROM plugin_user_config WHERE user_id = ?').all(userId) as Array<{ plugin_id: string; config: string }>) { let cfg: Record = {}; try { cfg = JSON.parse(c.config || '{}'); } catch { /* ignore */ } const secrets = secretKeys.get(c.plugin_id) ?? new Set(); @@ -379,7 +388,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { for (const [k, v] of Object.entries(cfg)) masked[k] = secrets.has(k) ? '***' : v; entryFor(c.plugin_id).settings = masked; } - for (const t of db.prepare('SELECT DISTINCT plugin_id FROM plugin_oauth_tokens WHERE user_id = ?').all(userId) as Array<{ plugin_id: string }>) { + for (const t of this.db.prepare('SELECT DISTINCT plugin_id FROM plugin_oauth_tokens WHERE user_id = ?').all(userId) as Array<{ plugin_id: string }>) { entryFor(t.plugin_id).oauthConnected = true; } } catch (err) { @@ -395,7 +404,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { * requires. Returns the ids actually deactivated. */ async deactivateForDisabledAddon(addonId: string): Promise { - const rows = db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[]; + const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[]; const directlyAffected = rows .filter((r) => r.enabled && parseDependencies(r.dependencies).requiredAddons.includes(addonId)) .map((r) => r.id); @@ -409,7 +418,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { /** Re-scan the plugins volume on demand (admin action). */ rescan(): { discovered: string[]; skipped: string[] } { - return discoverPlugins(db); + return discoverPlugins(this.db); } async onModuleDestroy(): Promise { @@ -457,7 +466,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { /** All plugin rows projected to what the dependency helpers reason over. */ private installedDepRows(): Map { - const rows = db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[]; + const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[]; return new Map(rows.map((r) => [r.id, r])); } @@ -467,7 +476,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { * permission re-consent → required addon disabled → missing/mismatched plugin dependency. */ private assertActivatable(id: string, installed: Map, consentWiden: boolean): void { - const row = db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range FROM plugins WHERE id = ?').get(id) as + const row = this.db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range FROM plugins WHERE id = ?').get(id) as | { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null } | undefined; if (!row) throw new Error(`plugin ${id} not found`); @@ -523,13 +532,13 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { /** Mark a (pre-validated) plugin enabled and spawn its child. */ private async spawnActivated(id: string): Promise { - const row = db.prepare('SELECT permissions, config FROM plugins WHERE id = ?').get(id) as + const row = this.db.prepare('SELECT permissions, config FROM plugins WHERE id = ?').get(id) as | { permissions: string; config: string } | undefined; if (!row) throw new Error(`plugin ${id} not found`); const declared = parseArray(row.permissions).filter(isKnownPermission); // Mark it enabled (admin intent) so it reboots after restarts/crashes. - db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id); + this.db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id); const config = decryptConfig(parseObject(row.config)); const manifestHosts = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length)); // Union in the hosts the ADMIN added post-install. A plugin that talks to a @@ -550,7 +559,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { /** Does this plugin's manifest declare that it needs operator-supplied hosts? */ wantsOperatorEgress(id: string): boolean { - const row = db.prepare('SELECT operator_egress FROM plugins WHERE id = ?').get(id) as { operator_egress: number } | undefined; + const row = this.db.prepare('SELECT operator_egress FROM plugins WHERE id = ?').get(id) as { operator_egress: number } | undefined; return row?.operator_egress === 1; } @@ -572,9 +581,9 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { if (host === '*' || !EGRESS_HOST_RE.test(host)) throw new ForbiddenResource(`invalid host "${raw}"`); if (!clean.includes(host)) clean.push(host); } - db.transaction(() => { - db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id); - const ins = db.prepare('INSERT OR IGNORE INTO plugin_egress_hosts (plugin_id, host) VALUES (?, ?)'); + this.db.transaction(() => { + this.db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id); + const ins = this.db.prepare('INSERT OR IGNORE INTO plugin_egress_hosts (plugin_id, host) VALUES (?, ?)'); for (const h of clean) ins.run(id, h); })(); // Re-spawn so a live child actually gets the new allow-list. @@ -588,7 +597,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { async deactivate(id: string): Promise { await this.supervisor.disable(id); closePluginDataDb(id); - db.prepare("UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id); + this.db.prepare("UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id); } /** @@ -601,7 +610,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { * restart of update()/sideload() never disables a plugin's dependents. */ async deactivateWithDependents(id: string): Promise { - const rows = db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[]; + const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[]; const enabledById = new Map(rows.map((r) => [r.id, r.enabled])); // findDependentsTransitive returns nearest-first; reverse so the deepest dependent // (the furthest caller) stops before the plugin it depends on. @@ -623,7 +632,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { * currently-running child untouched (it keeps serving the old code from memory). */ async update(id: string, opts?: { version?: string; retrustKey?: string }): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> { - const before = db.prepare('SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?').get(id) as + const before = this.db.prepare('SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?').get(id) as | { enabled: number; granted_permissions: string; version: string | null } | undefined; if (!before) throw new Error(`plugin ${id} not found`); @@ -642,7 +651,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { const res = await this.registry.install(id, { version: target, retrustKey: opts?.retrustKey }); const declared = parseArray( - (db.prepare('SELECT permissions FROM plugins WHERE id = ?').get(id) as { permissions: string }).permissions, + (this.db.prepare('SELECT permissions FROM plugins WHERE id = ?').get(id) as { permissions: string }).permissions, ).filter(isKnownPermission); const newGrants = declared.filter((p) => !granted.has(p)); const newEgress = newGrants.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length)).filter(Boolean); @@ -700,7 +709,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { actor: { userId: number | null; ip?: string | null }, ): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> { if (!this.registry) throw new Error('registry service unavailable'); - const before = db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string | null } | undefined; + const before = this.db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string | null } | undefined; const entry = await this.registry.assertRetrustable(id, publicKey); // throws unless SIGNATURE_KEY_CHANGED const res = await this.update(id, { version, retrustKey: publicKey }); @@ -735,7 +744,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { if (!this.registry) throw new Error('registry service unavailable'); const staged = this.registry.stageUpload(bytes); try { - const replaced = !!db.prepare('SELECT id FROM plugins WHERE id = ?').get(staged.id); + const replaced = !!this.db.prepare('SELECT id FROM plugins WHERE id = ?').get(staged.id); // Force any replaced plugin INACTIVE before the swap: stop a running child // (it holds file locks and would keep executing stale code) AND clear the // active flag, so replaced code can never keep running — or even show active @@ -772,7 +781,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { if (scanForNativeBinaries(sourceDir).length) throw new Error('directory contains native binaries'); const id = manifest.id; - const existing = db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined; + const existing = this.db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined; // Never clobber a REAL installed plugin (registry/sideload) — only re-point a link. if (existing && existing.source_repo !== DEV_LINK_SOURCE) { throw new Error(`a plugin '${id}' is already installed — uninstall it before dev-linking that id`); @@ -785,10 +794,10 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { removePluginCodeEntry(dest); // drop any prior link — never follows into the author's source fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.symlinkSync(sourceDir, dest, 'junction'); // Windows junction (no elevation); POSIX ignores the type -> dir symlink - discoverPlugins(db); // registers/updates the row from the linked manifest, INACTIVE + discoverPlugins(this.db); // registers/updates the row from the linked manifest, INACTIVE // Same as a sideload: the plugin has left the registry trust model, so a block that // described a refused REGISTRY update no longer describes the code that will run. - db.prepare( + this.db.prepare( `UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, author_pubkey = NULL, update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL, status = 'inactive', enabled = 0 @@ -807,7 +816,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { */ async reload(id: string): Promise { if (!devLinkEnabled()) throw new Error('dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)'); - const row = db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined; + const row = this.db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined; if (!row) throw new Error(`plugin ${id} not found`); if (row.source_repo !== DEV_LINK_SOURCE) throw new Error(`plugin ${id} is not dev-linked`); const wasActive = this.isActive(id); @@ -851,16 +860,16 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // Code always goes; the DB metadata + fields go so it disappears from the UI. // Link-safe: a dev-linked plugin only drops the symlink, never the author's source. removePluginCodeEntry(pluginCodeDir(id)); - db.prepare('DELETE FROM plugins WHERE id = ?').run(id); - db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(id); - try { db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(id); } catch { /* table absent */ } + this.db.prepare('DELETE FROM plugins WHERE id = ?').run(id); + this.db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(id); + try { this.db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(id); } catch { /* table absent */ } // The admin's egress consent dies with the plugin. Unconditional: leaving it would // silently grant a LATER plugin that reuses this id the hosts the admin approved for // a different one. - try { db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id); } catch { /* table absent */ } + try { this.db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id); } catch { /* table absent */ } // Scheduled tasks are operational (not user data), so they go unconditionally — // a scheduled callback for a plugin that no longer exists must never fire. - db.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?').run(id); + this.db.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?').run(id); // If it was a notification channel, retire the channel too. Unconditional, for the // same reason as the settings fields: these are TREK's config ABOUT the plugin, and // leaving them means a later plugin that reuses this id silently inherits every @@ -868,23 +877,23 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { this.retireNotificationChannel(id); if (deleteData) { removePluginData(id); - db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id); - db.prepare("DELETE FROM settings WHERE key LIKE ?").run(`plugin:${id}:%`); - db.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id = ?').run(id); + this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id); + this.db.prepare("DELETE FROM settings WHERE key LIKE ?").run(`plugin:${id}:%`); + this.db.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id = ?').run(id); // Per-user secrets + OAuth tokens/state live in their own tables, NOT under // settings — without these a "delete all data" leaves encrypted API keys and // refresh tokens behind, silently re-adopted if a plugin with the same id is // reinstalled. The migration ledger goes too, so a reinstall re-runs cleanly. - db.prepare('DELETE FROM plugin_user_config WHERE plugin_id = ?').run(id); - db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ?').run(id); - db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ?').run(id); - db.prepare('DELETE FROM plugin_meta_migrations WHERE plugin_id = ?').run(id); - db.prepare('DELETE FROM plugin_capability_audit WHERE plugin_id = ?').run(id); + this.db.prepare('DELETE FROM plugin_user_config WHERE plugin_id = ?').run(id); + this.db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ?').run(id); + this.db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ?').run(id); + this.db.prepare('DELETE FROM plugin_meta_migrations WHERE plugin_id = ?').run(id); + this.db.prepare('DELETE FROM plugin_capability_audit WHERE plugin_id = ?').run(id); // The plugin's data dir is gone now, so any pending GDPR erasure for it is moot. // But when deleteData is FALSE we deliberately KEEP the queue rows: the data dir // (which may still hold a deleted user's rows) survives, so the erasure obligation // must survive too — a reinstall of the same id drains the queue and honours it. - db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(id); + this.db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(id); } } @@ -894,7 +903,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { /** Declared outbound hosts (from http:outbound: grants) for the frame CSP. */ outboundHostsOf(id: string): string[] { - const row = db.prepare('SELECT granted_permissions FROM plugins WHERE id = ?').get(id) as + const row = this.db.prepare('SELECT granted_permissions FROM plugins WHERE id = ?').get(id) as | { granted_permissions: string } | undefined; if (!row) return []; @@ -972,8 +981,8 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { /** True if `caller` declares `target` as a plugin dependency whose range the * installed target version satisfies. */ private dependsOnSatisfied(callerId: string, targetId: string): boolean { - const caller = db.prepare('SELECT dependencies FROM plugins WHERE id = ?').get(callerId) as { dependencies: string | null } | undefined; - const target = db.prepare('SELECT version FROM plugins WHERE id = ?').get(targetId) as { version: string | null } | undefined; + const caller = this.db.prepare('SELECT dependencies FROM plugins WHERE id = ?').get(callerId) as { dependencies: string | null } | undefined; + const target = this.db.prepare('SELECT version FROM plugins WHERE id = ?').get(targetId) as { version: string | null } | undefined; if (!caller || !target) return false; const dep = parseDependencies(caller.dependencies).pluginDependencies.find((d) => d.id === targetId); if (!dep) return false; @@ -993,7 +1002,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // without the notification tables (a slimmed test app that imports only the plugin // module). try { - db.prepare('DELETE FROM notification_channel_preferences WHERE channel = ?').run(pluginChannelId(id)); + this.db.prepare('DELETE FROM notification_channel_preferences WHERE channel = ?').run(pluginChannelId(id)); } catch { /* no notifications schema here — nothing to retire */ } } @@ -1009,7 +1018,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { notificationChannels(): ExternalChannel[] { if (!pluginsEnabled()) return []; return this.supervisor.providersOf('notificationChannel').map((id) => { - const row = db.prepare('SELECT name, capabilities FROM plugins WHERE id = ?').get(id) as + const row = this.db.prepare('SELECT name, capabilities FROM plugins WHERE id = ?').get(id) as | { name: string; capabilities: string } | undefined; let cap: { title?: string; events?: string[] } = {}; @@ -1061,7 +1070,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { actionsOf(id: string): Array<{ key: string; label: string; hint?: string; danger: boolean }> { try { return ( - db.prepare('SELECT action_key, label, hint, danger FROM plugin_actions WHERE plugin_id = ? ORDER BY sort_order').all(id) as Array<{ + this.db.prepare('SELECT action_key, label, hint, danger FROM plugin_actions WHERE plugin_id = ? ORDER BY sort_order').all(id) as Array<{ action_key: string; label: string; hint: string | null; danger: number; }> ).map((r) => ({ key: r.action_key, label: r.label, hint: r.hint ?? undefined, danger: r.danger === 1 })); @@ -1097,7 +1106,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { /** A plugin's declared `capabilities.provides`/`capabilities.emits` (from the DB). */ private capabilityList(id: string, field: 'provides' | 'emits'): string[] { - const row = db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined; + const row = this.db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined; if (!row) return []; try { const c = JSON.parse(row.capabilities || '{}') as Record; diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index 45a7111ed..b8b60514d 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; import { pluginsEnabled } from './kill-switch'; import { devLinkEnabled } from './dev-link'; import { maybe_encrypt_api_key, decrypt_api_key } from '../../services/apiKeyCrypto'; @@ -99,8 +100,14 @@ export interface PluginListItem { @Injectable() export class PluginsService { + constructor(private readonly dbs: DatabaseService) {} + + private get db() { + return this.dbs.connection; + } + list(): { enabled: boolean; devLink: boolean; plugins: PluginListItem[] } { - const rows = db + const rows = this.db .prepare( `SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo, permissions, capabilities, dependencies, operator_egress, trek_range, @@ -160,12 +167,12 @@ export class PluginsService { * the config with secrets masked for the client. */ updateInstanceConfig(id: string, patch: Record): Record { - const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined; + const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined; if (!row) throw new Error(`plugin ${id} not found`); const secretKeys = new Set( ( - db + this.db .prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance' AND secret = 1") .all(id) as Array<{ field_key: string }> ).map((r) => r.field_key), @@ -180,13 +187,13 @@ export class PluginsService { config[k] = v; } } - db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id); + this.db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id); return maskSecrets(config, secretKeys); } /** The plugin's `scope:'user'` settings fields, in declared order (for the user form). */ userSettingsFields(id: string): Array> { - return db + return this.db .prepare( `SELECT field_key AS key, label, input_type, placeholder, hint, required, secret, options FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' ORDER BY sort_order, id`, @@ -210,7 +217,7 @@ export class PluginsService { private userSecretKeys(id: string): Set { return new Set( ( - db + this.db .prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND secret = 1") .all(id) as Array<{ field_key: string }> ).map((r) => r.field_key), @@ -219,7 +226,7 @@ export class PluginsService { /** A user's own config for a plugin, secrets masked (safe to send to the client). */ getUserConfig(id: string, userId: number): Record { - const row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as + const row = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as | { config: string } | undefined; return maskSecrets(safeParse(row?.config ?? '{}'), this.userSecretKeys(id)); @@ -229,12 +236,12 @@ export class PluginsService { * ciphertext). Only keys declared as `scope:'user'` fields are accepted. Returns masked. */ updateUserConfig(id: string, userId: number, patch: Record): Record { const allowed = new Set( - (db.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user'").all(id) as Array<{ field_key: string }>).map( + (this.db.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user'").all(id) as Array<{ field_key: string }>).map( (r) => r.field_key, ), ); const secretKeys = this.userSecretKeys(id); - const existing = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as + const existing = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as | { config: string } | undefined; const config = safeParse(existing?.config ?? '{}'); @@ -247,7 +254,7 @@ export class PluginsService { config[k] = v; } } - db.prepare( + this.db.prepare( `INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) VALUES (?, ?, ?, datetime('now')) ON CONFLICT(plugin_id, user_id) DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at`, ).run(id, userId, JSON.stringify(config)); @@ -257,7 +264,7 @@ export class PluginsService { /** A user's own config with secrets DECRYPTED — host-only, for runtime `ctx.settings`. * Never sent to a client; the acting user is resolved host-side. */ getUserConfigDecrypted(id: string, userId: number): Record { - const row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as + const row = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as | { config: string } | undefined; const config = safeParse(row?.config ?? '{}'); @@ -269,18 +276,18 @@ export class PluginsService { /** A plugin's error log, newest first. */ errors(id: string): Array<{ ts: string; level: string; message: string }> { - return db + return this.db .prepare('SELECT ts, level, message FROM plugin_error_log WHERE plugin_id = ? ORDER BY ts DESC, id DESC LIMIT 200') .all(id) as Array<{ ts: string; level: string; message: string }>; } clearErrors(id: string): void { - db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id); + this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id); } /** A plugin's hash-chained capability audit log, newest first. */ auditLog(id: string): unknown[] { - return readAudit(db, id); + return readAudit(this.db, id); } /** A plugin's broker budget usage for today (AI + notification counts vs caps). */ @@ -290,11 +297,11 @@ export class PluginsService { /** Read the instance config with secret fields masked. */ getInstanceConfig(id: string): Record { - const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined; + const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined; if (!row) throw new Error(`plugin ${id} not found`); const secretKeys = new Set( ( - db + this.db .prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance' AND secret = 1") .all(id) as Array<{ field_key: string }> ).map((r) => r.field_key), diff --git a/server/src/nest/plugins/registry/registry.service.ts b/server/src/nest/plugins/registry/registry.service.ts index d6a3d685a..225d76c87 100644 --- a/server/src/nest/plugins/registry/registry.service.ts +++ b/server/src/nest/plugins/registry/registry.service.ts @@ -1,5 +1,5 @@ import { readEnv } from '../../../app-config'; -import { db } from '../../../db/database'; +import { DatabaseService } from '../../database/database.service'; import { discoverPlugins } from '../install/discovery'; import { hostSatisfies, hostVersion, normalizedHost } from '../install/host-compat'; import type { PluginDependency } from '../install/manifest'; @@ -147,6 +147,12 @@ export class RegistryError extends Error { @Injectable() export class PluginRegistryService { + constructor(private readonly dbs: DatabaseService) {} + + private get db() { + return this.dbs.connection; + } + /** * Fetch the aggregated registry (cached, soft-fail, stale-serve). Pass * force=true (the admin "rescan" button) to bypass the 30-min cache and also @@ -435,8 +441,8 @@ export class PluginRegistryService { fs.renameSync(pluginRoot, dest); // 7. register INACTIVE (record provenance) - discoverPlugins(db); - db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run( + discoverPlugins(this.db); + this.db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run( entry.repo, ver.commitSha, ver.sha256, @@ -448,7 +454,7 @@ export class PluginRegistryService { // to a key the artifact just verified under; NEVER cleared to NULL, because a // NULL pin re-opens the "was never signed" path that accepts an unsigned update. if (entry.authorPublicKey) { - db.prepare('UPDATE plugins SET author_pubkey = ? WHERE id = ?').run(entry.authorPublicKey, id); + this.db.prepare('UPDATE plugins SET author_pubkey = ? WHERE id = ?').run(entry.authorPublicKey, id); } // The plugin is now on new code that passed every check — whatever refusal was // recorded before no longer describes reality. @@ -471,7 +477,7 @@ export class PluginRegistryService { constraint?: string, ): Promise<{ installed: string[]; requiredAddons: string[] }> { const installedNow = new Set( - (db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id), + (this.db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id), ); const done = new Set(); const installed: string[] = []; @@ -536,7 +542,7 @@ export class PluginRegistryService { fs.mkdirSync(pluginsCodeRoot(), { recursive: true }); fs.rmSync(dest, { recursive: true, force: true }); fs.renameSync(staged.root, dest); - discoverPlugins(db); + discoverPlugins(this.db); // Provenance for a sideload, plus a hard INACTIVE floor: discoverPlugins keeps // an existing row's status, so replacing a plugin that was active must not // leave the new code marked active — the admin re-activates (and re-consents @@ -546,7 +552,7 @@ export class PluginRegistryService { // plugin has just left the registry trust model entirely — the code is now whatever // the admin uploaded. Leaving the block would have the row insist an update was // blocked over a signing key that no longer applies to the code that is running. - db.prepare( + this.db.prepare( `UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ?, author_pubkey = NULL, update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL, status = 'inactive', enabled = 0 @@ -585,7 +591,7 @@ export class PluginRegistryService { retrustKey?: string, ): void { const pinned = - (db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined) + (this.db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined) ?.author_pubkey ?? null; if (!entry.authorPublicKey && !ver.signature) { @@ -637,7 +643,7 @@ export class PluginRegistryService { * rendered, the admin would be blessing a key they never saw. */ async assertRetrustable(id: string, publicKey: string): Promise { - const row = db.prepare('SELECT source_repo, author_pubkey FROM plugins WHERE id = ?').get(id) as + const row = this.db.prepare('SELECT source_repo, author_pubkey FROM plugins WHERE id = ?').get(id) as | { source_repo?: string | null; author_pubkey?: string | null } | undefined; if (!row) throw new RegistryError(`plugin ${id} not found`, 'NOT_FOUND'); diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index a30464b27..a296b9459 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; import { broadcast } from '../../websocket'; import { checkPermission } from '../../services/permissions'; import type { User } from '../../types'; @@ -19,6 +19,12 @@ type BudgetEntry = { total_price?: number; category?: string } | undefined; */ @Injectable() export class ReservationsService { + constructor(private readonly dbs: DatabaseService) {} + + private get db() { + return this.dbs.connection; + } + verifyTripAccess(tripId: string, userId: number) { return svc.verifyTripAccess(tripId, userId); } @@ -89,7 +95,7 @@ export class ReservationsService { // but only if it still carries the auto-derived category (so a manual pick in // the Costs editor is preserved). Runs regardless of create_budget_entry. if (type && currentType && type !== currentType) { - const linked = db.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number; category: string } | undefined; + const linked = this.db.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number; category: string } | undefined; if (linked) { const oldCat = typeToCostCategory(currentType); const newCat = typeToCostCategory(type); @@ -107,7 +113,7 @@ export class ReservationsService { if (!(Number(entry.total_price) > 0)) { // Explicit clear (total_price 0/empty) — drop the linked item. - const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined; + const linked = this.db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined; if (linked) { deleteBudgetItem(linked.id, tripId); broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId); @@ -118,13 +124,13 @@ export class ReservationsService { try { const itemName = title || currentTitle; const category = entry.category || type || currentType || 'Other'; - const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined; + const existing = this.db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined; if (existing) { const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price }); broadcast(tripId, 'budget:updated', { item: updated }, socketId); } else { const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price }); - db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id); + this.db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id); item.reservation_id = Number(id); broadcast(tripId, 'budget:created', { item }, socketId); } diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 618ef0e25..75fceb86f 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { db, canAccessTrip } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; import { broadcast } from '../../websocket'; import { checkPermission } from '../../services/permissions'; import type { User } from '../../types'; @@ -22,8 +22,14 @@ import { searchUnsplashPhotos, getUnsplashKey } from '../../services/unsplashSer */ @Injectable() export class TripsService { + constructor(private readonly dbs: DatabaseService) {} + + private get db() { + return this.dbs.connection; + } + canAccessTrip(tripId: string, userId: number) { - return canAccessTrip(tripId, userId) as { user_id: number } | null | undefined; + return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined; } can(action: string, role: string, ownerId: number | null, userId: number, isMember: boolean): boolean { @@ -84,7 +90,7 @@ export class TripsService { /** Re-read a freshly copied trip in list shape (mirrors the route's TRIP_SELECT query). */ getCopiedTrip(newTripId: number, userId: number) { - return db.prepare(`${tripSvc.TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId: newTripId }); + return this.db.prepare(`${tripSvc.TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId: newTripId }); } listMembers(tripId: string, ownerId: number) { diff --git a/server/tests/e2e/addons.e2e.test.ts b/server/tests/e2e/addons.e2e.test.ts index 9199707e3..d81aa7b91 100644 --- a/server/tests/e2e/addons.e2e.test.ts +++ b/server/tests/e2e/addons.e2e.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; +import { DatabaseModule } from '../../src/nest/database/database.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -46,7 +47,7 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [AddonsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AddonsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index 74e27cb07..393b65f27 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; +import { DatabaseModule } from '../../src/nest/database/database.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -44,7 +45,7 @@ describe('Budget e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [BudgetModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, BudgetModule] }).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 27cb2fb72..1e0516391 100644 --- a/server/tests/e2e/collab.e2e.test.ts +++ b/server/tests/e2e/collab.e2e.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; +import { DatabaseModule } from '../../src/nest/database/database.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -51,7 +52,7 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [CollabModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, CollabModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/feeds.e2e.test.ts b/server/tests/e2e/feeds.e2e.test.ts index c85ea0b0d..ccfcb0566 100644 --- a/server/tests/e2e/feeds.e2e.test.ts +++ b/server/tests/e2e/feeds.e2e.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; +import { DatabaseModule } from '../../src/nest/database/database.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -55,7 +56,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { let prevAppUrl: string | undefined; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [FeedsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, FeedsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index 5df022e44..149861c66 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; +import { DatabaseModule } from '../../src/nest/database/database.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -51,7 +52,7 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [JourneyModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, JourneyModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/packing.e2e.test.ts b/server/tests/e2e/packing.e2e.test.ts index 3e0679601..b11bdd907 100644 --- a/server/tests/e2e/packing.e2e.test.ts +++ b/server/tests/e2e/packing.e2e.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; +import { DatabaseModule } from '../../src/nest/database/database.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -48,7 +49,7 @@ describe('Packing e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [PackingModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, PackingModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index 203bdfa3e..7b59d47a7 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; +import { DatabaseModule } from '../../src/nest/database/database.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -59,7 +60,7 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [TripsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, TripsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/integration/plugins/dev-link.test.ts b/server/tests/integration/plugins/dev-link.test.ts index ebb6226ad..d9ab79f6a 100644 --- a/server/tests/integration/plugins/dev-link.test.ts +++ b/server/tests/integration/plugins/dev-link.test.ts @@ -27,6 +27,8 @@ const { testDb } = vi.hoisted(() => { return { testDb: db }; }); vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined })); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; @@ -58,7 +60,7 @@ beforeAll(() => { process.env.TREK_PLUGINS_DATA_DIR = dataRoot; process.env.TREK_PLUGINS_ENABLED = 'true'; process.env.TREK_PLUGINS_DEV_LINK = '1'; - runtime = new PluginRuntimeService(); + runtime = new PluginRuntimeService(new DatabaseService(dbConn)); }); afterAll(async () => { diff --git a/server/tests/integration/plugins/plugin-runtime.test.ts b/server/tests/integration/plugins/plugin-runtime.test.ts index f4931be61..e35372686 100644 --- a/server/tests/integration/plugins/plugin-runtime.test.ts +++ b/server/tests/integration/plugins/plugin-runtime.test.ts @@ -43,6 +43,8 @@ const { testDb } = vi.hoisted(() => { return { testDb: db }; }); vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined })); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { PluginRuntimeService, PluginDependencyError } from '../../../src/nest/plugins/plugin-runtime.service'; @@ -77,7 +79,7 @@ beforeAll(() => { ); testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run(); - runtime = new PluginRuntimeService(); + runtime = new PluginRuntimeService(new DatabaseService(dbConn)); }); afterAll(async () => { @@ -147,7 +149,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { it('onModuleInit is a no-op when the runtime is disabled', () => { process.env.TREK_PLUGINS_ENABLED = 'false'; - expect(() => new PluginRuntimeService().onModuleInit()).not.toThrow(); + expect(() => new PluginRuntimeService(new DatabaseService(dbConn)).onModuleInit()).not.toThrow(); process.env.TREK_PLUGINS_ENABLED = 'true'; }); @@ -170,7 +172,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { fs.mkdirSync(path.join(codeRoot, 'messy', 'server'), { recursive: true }); fs.writeFileSync(path.join(codeRoot, 'messy', 'server', 'index.js'), 'module.exports = { async onLoad() {} };'); testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('messy','inactive','not-json','not-json')").run(); - const rt = new PluginRuntimeService(); + const rt = new PluginRuntimeService(new DatabaseService(dbConn)); await rt.activate('messy'); // must not throw despite the garbage JSON expect(rt.isActive('messy')).toBe(true); await rt.deactivate('messy'); @@ -249,7 +251,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { // status='error' from a previous crash, but enabled=1 → must still boot testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('booter','error',1,'[]','{}')").run(); - const rt = new PluginRuntimeService(); + const rt = new PluginRuntimeService(new DatabaseService(dbConn)); rt.onModuleInit(); // fire-and-forget spawn for (let i = 0; i < 40 && !rt.isActive('booter'); i++) await new Promise((r) => setTimeout(r, 50)); expect(rt.isActive('booter')).toBe(true); @@ -261,7 +263,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { fs.writeFileSync(path.join(codeRoot, 'sleeper', 'server', 'index.js'), 'module.exports = { async onLoad() {} };'); testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('sleeper','inactive',0,'[]','{}')").run(); - const rt = new PluginRuntimeService(); + const rt = new PluginRuntimeService(new DatabaseService(dbConn)); rt.onModuleInit(); await new Promise((r) => setTimeout(r, 300)); expect(rt.isActive('sleeper')).toBe(false); @@ -269,7 +271,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { it('outboundHostsOf extracts declared http:outbound hosts', () => { testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('net','inactive','[\"db:own\",\"http:outbound:api.x.com\",\"http:outbound:*.y.com\"]','{}')").run(); - const rt = new PluginRuntimeService(); + const rt = new PluginRuntimeService(new DatabaseService(dbConn)); expect(rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']); expect(rt.outboundHostsOf('missing')).toEqual([]); }); @@ -291,7 +293,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { testDb.prepare("INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES ('gone', 'poll', 0)").run(); testDb.prepare("INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES ('gone', 7)").run(); - await new PluginRuntimeService().uninstall('gone', true); + await new PluginRuntimeService(new DatabaseService(dbConn)).uninstall('gone', true); expect(fs.existsSync(path.join(codeRoot, 'gone'))).toBe(false); expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id='gone'").get()).toMatchObject({ c: 0 }); @@ -310,7 +312,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { // a deleted user's erasure is still pending for this plugin testDb.prepare("INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES ('keepdata', 42)").run(); - await new PluginRuntimeService().uninstall('keepdata', false); // keep the plugin's data + await new PluginRuntimeService(new DatabaseService(dbConn)).uninstall('keepdata', false); // keep the plugin's data // the plugin row is gone, but the erasure obligation is retained — a reinstall of the // same id will drain it and finally honour the deletion, instead of silently keeping @@ -325,12 +327,12 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { "module.exports = { async onLoad() { await fetch('https://blocked.example/x'); } };"); testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('nettry','inactive','[]','{}')").run(); // no http:outbound granted -> egress guard blocks all outbound -> onLoad throws - await expect(new PluginRuntimeService().activate('nettry')).rejects.toThrow(/egress/); - await new PluginRuntimeService().deactivate('nettry').catch(() => {}); + await expect(new PluginRuntimeService(new DatabaseService(dbConn)).activate('nettry')).rejects.toThrow(/egress/); + await new PluginRuntimeService(new DatabaseService(dbConn)).deactivate('nettry').catch(() => {}); }); it('onModuleDestroy tears down cleanly', async () => { - await expect(new PluginRuntimeService().onModuleDestroy()).resolves.toBeUndefined(); + await expect(new PluginRuntimeService(new DatabaseService(dbConn)).onModuleDestroy()).resolves.toBeUndefined(); }); }); @@ -352,7 +354,7 @@ describe('PluginRuntimeService.update (re-consent gate)', () => { it('restarts transparently when the new version requests no new permissions', async () => { seed('upd-same', 1, ['db:own'], ['db:own']); - const rt = new PluginRuntimeService(fakeRegistry(() => {})); // declared perms unchanged + const rt = new PluginRuntimeService(new DatabaseService(dbConn), fakeRegistry(() => {})); // declared perms unchanged await rt.activate('upd-same'); const res = await rt.update('upd-same'); expect(res).toMatchObject({ activated: true, newPermissions: [], newEgress: [] }); @@ -363,6 +365,7 @@ describe('PluginRuntimeService.update (re-consent gate)', () => { it('leaves the plugin inactive and reports the delta when new rights are requested', async () => { seed('upd-wider', 1, ['db:own'], ['db:own']); const rt = new PluginRuntimeService( + new DatabaseService(dbConn), fakeRegistry(() => { testDb.prepare("UPDATE plugins SET permissions = ? WHERE id = 'upd-wider'") .run(JSON.stringify(['db:own', 'db:read:trips', 'http:outbound:api.new.com'])); @@ -379,17 +382,17 @@ describe('PluginRuntimeService.update (re-consent gate)', () => { it('a disabled plugin stays disabled even with no new permissions', async () => { seed('upd-off', 0, ['db:own'], ['db:own']); - const res = await new PluginRuntimeService(fakeRegistry(() => {})).update('upd-off'); + const res = await new PluginRuntimeService(new DatabaseService(dbConn), fakeRegistry(() => {})).update('upd-off'); expect(res).toMatchObject({ activated: false, newPermissions: [], newEgress: [] }); }); it('throws for an unknown plugin id', async () => { - await expect(new PluginRuntimeService(fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/); + await expect(new PluginRuntimeService(new DatabaseService(dbConn), fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/); }); it('throws if no registry service is wired', async () => { seed('upd-noreg', 0, ['db:own'], ['db:own']); - await expect(new PluginRuntimeService().update('upd-noreg')).rejects.toThrow(/registry service unavailable/); + await expect(new PluginRuntimeService(new DatabaseService(dbConn)).update('upd-noreg')).rejects.toThrow(/registry service unavailable/); }); }); @@ -556,7 +559,7 @@ describe('PluginRuntimeService.retrust', () => { it('re-pins AND installs in one call: the new key is pinned, the version moved, the block gone', async () => { seed('rt-ok', 'OLDKEY'); const registry = registryFor('rt-ok'); - const rt = new PluginRuntimeService(registry); + const rt = new PluginRuntimeService(new DatabaseService(dbConn), registry); await rt.activate('rt-ok'); await rt.retrust('rt-ok', '2.0.0', 'NEWKEY', { userId: 1, ip: '10.0.0.1' }); @@ -601,7 +604,7 @@ describe('PluginRuntimeService.retrust', () => { seed('rt-no', 'OLDKEY'); const registry = registryFor('rt-no'); vi.mocked(registry.assertRetrustable).mockRejectedValue(new Error('nothing to re-trust')); - const rt = new PluginRuntimeService(registry); + const rt = new PluginRuntimeService(new DatabaseService(dbConn), registry); await expect(rt.retrust('rt-no', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/); @@ -627,7 +630,7 @@ describe('PluginRuntimeService.retrust', () => { // exactly the "quietly stops updating" failure the block exists to prevent. it('activate does NOT clear a recorded update block', async () => { seed('rt-act', 'OLDKEY'); - const rt = new PluginRuntimeService(registryFor('rt-act')); + const rt = new PluginRuntimeService(new DatabaseService(dbConn), registryFor('rt-act')); await rt.deactivate('rt-act'); await rt.activate('rt-act'); @@ -663,7 +666,7 @@ describe('TREK host-version gating', () => { it('refuses to activate a plugin this TREK has outgrown, and says so with a code', async () => { process.env.APP_VERSION = '4.0.0'; seedRanged('outgrown', '>=3.2.0 <4.0.0'); - const err = await new PluginRuntimeService().activate('outgrown').catch((e) => e); + const err = await new PluginRuntimeService(new DatabaseService(dbConn)).activate('outgrown').catch((e) => e); expect(err).toBeInstanceOf(PluginDependencyError); expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' }); expect(err.detail).toMatchObject({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' }); @@ -675,14 +678,14 @@ describe('TREK host-version gating', () => { it('refuses a plugin that never declared a range at all', async () => { process.env.APP_VERSION = '3.3.0'; seedRanged('undeclared', null); - await expect(new PluginRuntimeService().activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' }); + await expect(new PluginRuntimeService(new DatabaseService(dbConn)).activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' }); cleanup('undeclared'); }); it('activates normally when the range admits the host', async () => { process.env.APP_VERSION = '3.3.0'; seedRanged('fits', '>=3.2.0 <4.0.0'); - const rt = new PluginRuntimeService(); + const rt = new PluginRuntimeService(new DatabaseService(dbConn)); await rt.activate('fits'); expect(rt.isActive('fits')).toBe(true); await rt.deactivate('fits'); @@ -696,7 +699,7 @@ describe('TREK host-version gating', () => { // panel showing a green, running plugin that does not exist. process.env.APP_VERSION = '4.0.0'; seedRanged('boot-broken', '>=3.2.0 <4.0.0', 1); - const rt = new PluginRuntimeService(); + const rt = new PluginRuntimeService(new DatabaseService(dbConn)); expect(() => rt.onModuleInit()).not.toThrow(); for (let i = 0; i < 40; i++) { const row = testDb.prepare("SELECT enabled FROM plugins WHERE id='boot-broken'").get() as { enabled: number }; @@ -714,7 +717,7 @@ describe('TREK host-version gating', () => { fs.mkdirSync(path.join(codeRoot, 'both-wrong', 'server'), { recursive: true }); fs.writeFileSync(path.join(codeRoot, 'both-wrong', 'server', 'index.js'), 'module.exports = {};'); testDb.prepare("INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range) VALUES ('both-wrong','inactive',0,'[\"db:own\"]','[]','{}','>=3.2.0 <4.0.0')").run(); - await expect(new PluginRuntimeService().activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' }); + await expect(new PluginRuntimeService(new DatabaseService(dbConn)).activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' }); cleanup('both-wrong'); }); }); diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts index 7f9869bbf..000ad3756 100644 --- a/server/tests/integration/plugins/registry.test.ts +++ b/server/tests/integration/plugins/registry.test.ts @@ -29,6 +29,8 @@ const { testDb } = vi.hoisted(() => { return { testDb: db }; }); vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined })); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; import { PluginRegistryService, RegistryError, __clearRegistryCacheForTests } from '../../../src/nest/plugins/registry/registry.service'; @@ -86,7 +88,7 @@ beforeEach(() => { testDb.exec('DELETE FROM plugins; DELETE FROM plugin_settings_fields; DELETE FROM plugin_error_log'); __clearRegistryCacheForTests(); vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response)); - svc = new PluginRegistryService(); + svc = new PluginRegistryService(new DatabaseService(dbConn)); }); afterEach(() => { vi.unstubAllGlobals(); @@ -236,7 +238,7 @@ describe('PluginRegistryService', () => { it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => { __clearRegistryCacheForTests(); vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); - expect((await new PluginRegistryService().fetchRegistry()).plugins).toEqual([]); + expect((await new PluginRegistryService(new DatabaseService(dbConn)).fetchRegistry()).plugins).toEqual([]); }); it('installs a pinned version end to end (verify -> extract -> register inactive)', async () => { diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index 8e6b107a4..1208df9ce 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -7,6 +7,8 @@ const { dbMock } = vi.hoisted(() => { return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } }; }); vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} })); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; const { getBagTracking, getCollabFeatures } = vi.hoisted(() => ({ getBagTracking: vi.fn(() => ({ enabled: false })), @@ -20,7 +22,7 @@ vi.mock('../../../src/services/memories/helpersService', () => ({ getPhotoProvid import { AddonsService } from '../../../src/nest/addons/addons.service'; function svc() { - return new AddonsService(); + return new AddonsService(new DatabaseService(dbConn)); } // Feed the three reads in order: addons, providers, fields. diff --git a/server/tests/unit/nest/booking-import/booking-import.service.test.ts b/server/tests/unit/nest/booking-import/booking-import.service.test.ts index 48320fe68..9b69692ba 100644 --- a/server/tests/unit/nest/booking-import/booking-import.service.test.ts +++ b/server/tests/unit/nest/booking-import/booking-import.service.test.ts @@ -4,6 +4,8 @@ import { HttpException } from '@nestjs/common'; // Mock the heavy side-effect imports so the service module loads cleanly; the // preview() path under test only touches the extractor + llmParse deps. vi.mock('../../../../src/db/database', () => ({ db: { prepare: vi.fn() }, closeDb: () => {}, reinitialize: () => {} })); +import { db as dbConn } from '../../../../src/db/database'; +import { DatabaseService } from '../../../../src/nest/database/database.service'; vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn() })); vi.mock('../../../../src/services/permissions', () => ({ checkPermission: vi.fn(() => true) })); vi.mock('../../../../src/services/tripAccess', () => ({ verifyTripAccess: vi.fn() })); @@ -19,7 +21,7 @@ const file = (name = 'a.pdf') => ({ buffer: Buffer.from('x'), originalname: name function make(opts: { kit?: boolean; ai?: boolean; extract?: any; parse?: any }) { const extractor = { isAvailable: () => opts.kit ?? false, extract: vi.fn(opts.extract ?? (async () => [])) }; const llmParse = { isAvailable: () => opts.ai ?? false, parse: vi.fn(opts.parse ?? (async () => ({ kiItems: [], warnings: [] }))) }; - return { svc: new BookingImportService(extractor as any, llmParse as any), extractor, llmParse }; + return { svc: new BookingImportService(extractor as any, llmParse as any, new DatabaseService(dbConn)), extractor, llmParse }; } beforeEach(() => vi.clearAllMocks()); diff --git a/server/tests/unit/nest/budget.service.test.ts b/server/tests/unit/nest/budget.service.test.ts index 566825525..d83d22ad2 100644 --- a/server/tests/unit/nest/budget.service.test.ts +++ b/server/tests/unit/nest/budget.service.test.ts @@ -6,6 +6,8 @@ const { dbMock } = vi.hoisted(() => { return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } }; }); vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} })); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast })); @@ -42,7 +44,7 @@ vi.mock('../../../src/services/budgetService', () => budget); import { BudgetService } from '../../../src/nest/budget/budget.service'; function svc() { - return new BudgetService(); + return new BudgetService(new DatabaseService(dbConn)); } beforeEach(() => { diff --git a/server/tests/unit/nest/packing.service.test.ts b/server/tests/unit/nest/packing.service.test.ts index 081667ccd..6a94813dc 100644 --- a/server/tests/unit/nest/packing.service.test.ts +++ b/server/tests/unit/nest/packing.service.test.ts @@ -5,6 +5,8 @@ const { dbMock } = vi.hoisted(() => { return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } }; }); vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} })); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast })); @@ -29,7 +31,7 @@ vi.mock('../../../src/services/notificationService', () => ({ send })); import { PackingService } from '../../../src/nest/packing/packing.service'; function svc() { - return new PackingService(); + return new PackingService(new DatabaseService(dbConn)); } beforeEach(() => vi.clearAllMocks()); diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index d0ff8277c..288aa0198 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -6,6 +6,8 @@ const { dbMock } = vi.hoisted(() => { return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } }; }); vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} })); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast })); @@ -31,7 +33,7 @@ vi.mock('../../../src/services/reservationService', () => resv); import { ReservationsService } from '../../../src/nest/reservations/reservations.service'; function svc() { - return new ReservationsService(); + return new ReservationsService(new DatabaseService(dbConn)); } beforeEach(() => { diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index f8ba72089..2396baea7 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -6,6 +6,8 @@ const { dbMock } = vi.hoisted(() => { }); const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn(() => ({ user_id: 1 })) })); vi.mock('../../../src/db/database', () => ({ db: dbMock, canAccessTrip, closeDb: () => {}, reinitialize: () => {} })); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast })); @@ -37,7 +39,7 @@ vi.mock('../../../src/services/fileService', () => ({ listFiles: () => [] })); import { TripsService } from '../../../src/nest/trips/trips.service'; import { rebaseTripCurrency } from '../../../src/services/budgetService'; -function svc() { return new TripsService(); } +function svc() { return new TripsService(new DatabaseService(dbConn)); } beforeEach(() => vi.clearAllMocks()); describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => { diff --git a/server/tests/unit/plugins/operator-egress.test.ts b/server/tests/unit/plugins/operator-egress.test.ts index ab54691f5..160b386e0 100644 --- a/server/tests/unit/plugins/operator-egress.test.ts +++ b/server/tests/unit/plugins/operator-egress.test.ts @@ -20,6 +20,8 @@ const { testDb, dbMock } = vi.hoisted(() => { return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } }; }); vi.mock('../../../src/db/database', () => dbMock); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} })); import { createTables } from '../../../src/db/schema'; @@ -42,7 +44,7 @@ beforeEach(() => { testDb.prepare('DELETE FROM plugins').run(); testDb.prepare('DELETE FROM plugin_egress_hosts').run(); testDb.prepare('DELETE FROM plugin_actions').run(); - rt = new PluginRuntimeService(); + rt = new PluginRuntimeService(new DatabaseService(dbConn)); }); describe('operator-supplied egress hosts', () => { @@ -156,13 +158,13 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', ( install('gotify', true); install('plain', false); - const before = new PluginsService().list().plugins; + const before = new PluginsService(new DatabaseService(dbConn)).list().plugins; expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 }); // A plugin that never asked for it must never invite the admin to add hosts. expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 }); await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']); - const after = new PluginsService().list().plugins; + const after = new PluginsService(new DatabaseService(dbConn)).list().plugins; expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2); delete process.env.TREK_PLUGINS_ENABLED; }); diff --git a/server/tests/unit/plugins/plugin-oauth.service.test.ts b/server/tests/unit/plugins/plugin-oauth.service.test.ts index 644cfafa5..79763dba9 100644 --- a/server/tests/unit/plugins/plugin-oauth.service.test.ts +++ b/server/tests/unit/plugins/plugin-oauth.service.test.ts @@ -14,6 +14,8 @@ vi.mock('../../../src/services/notifications', () => ({ getAppUrl: () => 'https: const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } })); vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } })); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; // The token POST now runs through the SSRF guard (ssrfGuard.safeFetchLlm), which // resolves the host before fetching. Stub DNS so the fake provider.example host @@ -50,12 +52,12 @@ const NOW = 1_700_000_000_000; describe('PluginOAuthService', () => { let svc: PluginOAuthService; - beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; }); + beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(new DatabaseService(dbConn)); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; }); it('providerConfig returns null unless every piece is present, decrypting the secrets', () => { expect(svc.providerConfig('p')).toMatchObject({ clientId: 'client-123', clientSecret: 'secret-abc', scopes: 'read write' }); getDb.current = freshDb({ ...CFG, oauth_client_secret: '' }); - expect(new PluginOAuthService().providerConfig('p')).toBeNull(); + expect(new PluginOAuthService(new DatabaseService(dbConn)).providerConfig('p')).toBeNull(); }); it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', () => { @@ -76,18 +78,18 @@ describe('PluginOAuthService', () => { it('rejects a non-https / loopback / metadata / internal authorize endpoint', () => { getDb.current = freshDb({ ...CFG, oauth_authorize_url: 'http://provider.example/authorize' }); - expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/https/); + expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/https/); getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://127.0.0.1/token' }); - expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback|private/); + expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|private/); // IPv6-literal loopback must not slip past the fast-fail getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://[::1]/token' }); - expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback/); + expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback/); // cloud-metadata by literal is refused too getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://169.254.169.254/token' }); - expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback|metadata/); + expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|metadata/); // an internal name suffix is refused getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://idp.internal/token' }); - expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/local/); + expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/local/); }); it('completeCallback verifies state (single-use, user-bound, TTL), exchanges the code, encrypts tokens', async () => { diff --git a/server/tests/unit/plugins/plugin-user-settings.test.ts b/server/tests/unit/plugins/plugin-user-settings.test.ts index bea9c39a5..8d1920e9a 100644 --- a/server/tests/unit/plugins/plugin-user-settings.test.ts +++ b/server/tests/unit/plugins/plugin-user-settings.test.ts @@ -15,6 +15,8 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({ const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } })); vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } })); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; import Database from 'better-sqlite3'; import { PluginsService, readUserSettingDecrypted } from '../../../src/nest/plugins/plugins.service'; @@ -35,7 +37,7 @@ function freshDb() { describe('per-user plugin settings', () => { let svc: PluginsService; - beforeEach(() => { getDb.current = freshDb(); svc = new PluginsService(); }); + beforeEach(() => { getDb.current = freshDb(); svc = new PluginsService(new DatabaseService(dbConn)); }); it('lists only the user-scope fields, in order', () => { const fields = svc.userSettingsFields('p'); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index 2a5e7ba81..136e648a7 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -17,6 +17,8 @@ const { testDb } = vi.hoisted(() => { return { testDb: db }; }); vi.mock('../../../src/db/database', () => ({ db: testDb })); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; import { PluginsService } from '../../../src/nest/plugins/plugins.service'; import { PluginsController } from '../../../src/nest/plugins/plugins.controller'; @@ -38,7 +40,7 @@ describe('PluginsService.list', () => { .run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0'); process.env.TREK_PLUGINS_ENABLED = 'true'; - const out = new PluginsService().list(); + const out = new PluginsService(new DatabaseService(dbConn)).list(); expect(out.enabled).toBe(true); expect(out.plugins).toHaveLength(1); expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' }); @@ -49,14 +51,14 @@ describe('PluginsService.list', () => { .prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)') .run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0'); - const out = new PluginsService().list(); + const out = new PluginsService(new DatabaseService(dbConn)).list(); expect(out.enabled).toBe(true); expect(out.plugins).toHaveLength(1); }); it('reports disabled when the kill switch is off (TREK_PLUGINS_ENABLED=false)', () => { process.env.TREK_PLUGINS_ENABLED = 'false'; - const out = new PluginsService().list(); + const out = new PluginsService(new DatabaseService(dbConn)).list(); expect(out.enabled).toBe(false); expect(out.plugins).toEqual([]); }); @@ -76,7 +78,7 @@ describe('PluginsService.list', () => { const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd'; insert('signed-one', 'acme/signed-one', key); - const p = new PluginsService().list().plugins[0]; + const p = new PluginsService(new DatabaseService(dbConn)).list().plugins[0]; expect(p.signed).toBe(true); // Short head…tail, for eyeballing against what the author reads out over the // phone. NOT a confidentiality measure — the key is public, and the re-trust @@ -86,7 +88,7 @@ describe('PluginsService.list', () => { it('reports unsigned for a registry plugin with no pinned key', () => { insert('plain', 'acme/plain', null); - const p = new PluginsService().list().plugins[0]; + const p = new PluginsService(new DatabaseService(dbConn)).list().plugins[0]; expect(p.signed).toBe(false); expect(p.keyFingerprint).toBeNull(); }); @@ -94,7 +96,7 @@ describe('PluginsService.list', () => { it('reports unsigned for a sideloaded and a dev-linked plugin (they carry no key)', () => { insert('uploaded', 'local:upload', null); insert('linked', 'local:link', null); - const plugins = new PluginsService().list().plugins; + const plugins = new PluginsService(new DatabaseService(dbConn)).list().plugins; expect(plugins.map((p) => [p.id, p.signed, p.source_repo])).toEqual([ ['linked', false, 'local:link'], ['uploaded', false, 'local:upload'], @@ -108,14 +110,14 @@ describe('PluginsService.list', () => { .prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?') .run('SIGNATURE_KEY_CHANGED', 'the key changed', '2.0.0', 'blocked'); - const byId = Object.fromEntries(new PluginsService().list().plugins.map((p) => [p.id, p])); + const byId = Object.fromEntries(new PluginsService(new DatabaseService(dbConn)).list().plugins.map((p) => [p.id, p])); expect(byId.blocked.updateBlock).toEqual({ code: 'SIGNATURE_KEY_CHANGED', detail: 'the key changed', version: '2.0.0' }); expect(byId.fine.updateBlock).toBeNull(); }); it('never leaks the raw pinned key into the list response (only the fingerprint)', () => { insert('signed-one', 'acme/signed-one', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd'); - const p = new PluginsService().list().plugins[0] as Record; + const p = new PluginsService(new DatabaseService(dbConn)).list().plugins[0] as Record; expect(p.author_pubkey).toBeUndefined(); }); }); @@ -133,7 +135,7 @@ describe('PluginsFeedController (client feed)', () => { it('returns active plugins when enabled, nothing when disabled', () => { testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('w','W','widget','Box','active')").run(); testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('i','I','integration','Plug','inactive')").run(); - const feed = new PluginsFeedController(); + const feed = new PluginsFeedController(new DatabaseService(dbConn)); process.env.TREK_PLUGINS_ENABLED = 'true'; const active = feed.list(); @@ -147,7 +149,7 @@ describe('PluginsFeedController (client feed)', () => { testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run(); testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run(); process.env.TREK_PLUGINS_ENABLED = 'true'; - const out = new PluginsFeedController().list(); + const out = new PluginsFeedController(new DatabaseService(dbConn)).list(); expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero'); expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar'); }); @@ -155,14 +157,14 @@ describe('PluginsFeedController (client feed)', () => { it('exposes the day-detail slot (a day-panel widget must not fall back to the dashboard)', () => { testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('d','D','widget','Box','active','{\"widget\":{\"slot\":\"day-detail\"}}')").run(); process.env.TREK_PLUGINS_ENABLED = 'true'; - expect(new PluginsFeedController().list().plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail'); + expect(new PluginsFeedController(new DatabaseService(dbConn)).list().plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail'); }); it('exposes settingsUi only when the capability is exactly true', () => { testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('su','S','widget','Box','active','{\"settingsUi\":true}')").run(); testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('no','N','widget','Box','active','{\"settingsUi\":\"yes\"}')").run(); process.env.TREK_PLUGINS_ENABLED = 'true'; - const out = new PluginsFeedController().list(); + const out = new PluginsFeedController(new DatabaseService(dbConn)).list(); expect(out.plugins.find((p) => p.id === 'su')?.settingsUi).toBe(true); expect(out.plugins.find((p) => p.id === 'no')?.settingsUi).toBeUndefined(); }); @@ -170,7 +172,7 @@ describe('PluginsFeedController (client feed)', () => { it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', () => { testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('r','R','widget','Box','active','{\"widget\":{\"slot\":\"reservation-detail\"}}')").run(); process.env.TREK_PLUGINS_ENABLED = 'true'; - expect(new PluginsFeedController().list().plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail'); + expect(new PluginsFeedController(new DatabaseService(dbConn)).list().plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail'); }); it('exposes tripPage for trip-page plugins, re-validated against the replaceable-tab whitelist', () => { @@ -180,7 +182,7 @@ describe('PluginsFeedController (client feed)', () => { // the capability is meaningless off a trip-page and must not leak onto widgets testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('w2','W2','widget','Box','active','{\"tripPage\":{\"replaces\":[\"transports\"]}}')").run(); process.env.TREK_PLUGINS_ENABLED = 'true'; - const out = new PluginsFeedController().list(); + const out = new PluginsFeedController(new DatabaseService(dbConn)).list(); expect(out.plugins.find((p) => p.id === 't')?.tripPage).toEqual({ replaces: ['transports', 'buchungen'], position: 1 }); expect(out.plugins.find((p) => p.id === 'evil')?.tripPage).toBeUndefined(); expect(out.plugins.find((p) => p.id === 'w2')?.tripPage).toBeUndefined(); @@ -236,7 +238,7 @@ describe('PluginsService instance config', () => { testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('x','X','inactive','{}')").run(); testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','api_key','instance',1)").run(); - const svc = new PluginsService(); + const svc = new PluginsService(new DatabaseService(dbConn)); const masked = svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' }); // client gets the masked view expect(masked.api_key).toBe('••••••••'); @@ -257,8 +259,8 @@ describe('PluginsService instance config', () => { }); it('throws for an unknown plugin', () => { - expect(() => new PluginsService().updateInstanceConfig('nope', {})).toThrow(/not found/); - expect(() => new PluginsService().getInstanceConfig('nope')).toThrow(/not found/); + expect(() => new PluginsService(new DatabaseService(dbConn)).updateInstanceConfig('nope', {})).toThrow(/not found/); + expect(() => new PluginsService(new DatabaseService(dbConn)).getInstanceConfig('nope')).toThrow(/not found/); }); }); @@ -266,7 +268,7 @@ describe('PluginsService error log', () => { beforeEach(() => testDb.exec('DELETE FROM plugin_error_log')); it('lists and clears a plugin error log', () => { testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES ('p','error','boom')").run(); - const svc = new PluginsService(); + const svc = new PluginsService(new DatabaseService(dbConn)); expect(svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]); svc.clearErrors('p'); expect(svc.errors('p')).toEqual([]); diff --git a/server/tests/unit/plugins/provider-hook-grant.test.ts b/server/tests/unit/plugins/provider-hook-grant.test.ts index c9d1918b2..f6aa1fa12 100644 --- a/server/tests/unit/plugins/provider-hook-grant.test.ts +++ b/server/tests/unit/plugins/provider-hook-grant.test.ts @@ -11,6 +11,8 @@ import { describe, it, expect } from 'vitest'; import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor'; import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { db } from '../../../src/db/database'; function makeSupervisor(): PluginSupervisor { // createRpcHost is never called on the providersOf path (no spawn). @@ -85,7 +87,7 @@ describe('providersOf enforces the hook:* grant', () => { describe('runtime.invokeHook defense-in-depth', () => { it('refuses a plugin id that is not a granted provider of the hook, even if passed directly', async () => { - const rt = new PluginRuntimeService(); + const rt = new PluginRuntimeService(new DatabaseService(db)); // one legitimate granted provider exists, so providersOf('placeDetailProvider') = ['ok'] // eslint-disable-next-line @typescript-eslint/no-explicit-any (rt as any).supervisor.running.set('ok', { id: 'ok', status: 'active', hooks: ['placeDetailProvider'], events: [], granted: new Set(['hook:place-detail-provider']) }); diff --git a/server/tests/unit/plugins/settings-isolation.test.ts b/server/tests/unit/plugins/settings-isolation.test.ts index 83f862b22..7c888a03a 100644 --- a/server/tests/unit/plugins/settings-isolation.test.ts +++ b/server/tests/unit/plugins/settings-isolation.test.ts @@ -17,6 +17,8 @@ const { testDb, dbMock } = vi.hoisted(() => { return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } }; }); vi.mock('../../../src/db/database', () => dbMock); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} })); import { createTables } from '../../../src/db/schema'; @@ -120,7 +122,7 @@ describe('a plugin channel label is bounded by the host', () => { VALUES ('loud', 'Loud', 'active', 1, '1.0.0', '[]', '[]', ?, '{}')`, ).run(JSON.stringify({ notificationChannel: { title: '🎉'.repeat(5) + 'A'.repeat(500) } })); - const rt = new PluginRuntimeService(); + const rt = new PluginRuntimeService(new DatabaseService(dbConn)); // Stand the plugin up as a granted, active notificationChannel provider. (rt as unknown as { supervisor: { running: Map } }).supervisor.running.set('loud', { id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']),