From ba7d55f999f20b6082cf352ede7f1d1113ae79e2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 10 Aug 2026 19:35:47 +0200 Subject: [PATCH] Finish the contract ratchet, the upload bridge and the client leftovers BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their body with a createZodDto class, so the fail-closed boot gate has nothing left to forgive; it still refuses a boot for a new handler that reads @Body() without one. Several of those contracts describe rather than constrain, and that is the whole care in this change. The pipe runs before the handler, so a schema stricter than the handler moves a user-visible answer: - TagsController.create answers { error: 'Tag name is required' }, pinned byte-for-byte in two tests, and the pipe formats as `field: message`. The DTO leaves name optional and the handler keeps the check that owns the message. Same shape for the booking-import confirm body, which additionally gains per-item validation the handler never did. - PluginsController.activate and install answer 503 when plugins are disabled, and check that BEFORE looking at the body. A schema that rejected first would turn that into a 400 for an operator whose server has plugins off. - setEgressHosts reads a non-array `hosts` as "clear the list", which is the documented reset. Typing it as string[] would have removed a working admin action rather than tightened anything. - PluginRoutesController.route answers { route: null } at 200 for everything it dislikes, so the client falls back to straight lines. A 400 there would turn a graceful degradation into a visible error. files.bridge is gone. The multer options are built by MulterModule.registerAsync from an AllowedFileTypesService leaf, so the fileFilter reads the operator's extension list from the container instead of from a second FilesService built outside it. Nest injects MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is not a boot error: it silently falls back to defaults, with no filter and no size cap. FILE-020 already proved that for trip files by uploading an .svg over HTTP and expecting 400; journey uploads had no such case, and now do. VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg regardless of what the client sends, which guards the public share proxy. PluginFrame gets an onError. A frame whose document fails to load left a blank element at full height, which reads as a hung app rather than as a plugin that is gone. typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup is left as it is: the build puts it in a lazy helpers chunk, not the entry chunk, and the remaining split would trade a synchronous render for an async one to move 172 kB that is already off the critical path. --- .github/workflows/test.yml | 7 ++ client/src/components/Plugins/PluginFrame.tsx | 21 +++++- server/package.json | 1 + .../nest/common/body-contract-allow-list.ts | 34 +++------ .../nest/files/allowed-file-types.module.ts | 12 +++ .../nest/files/allowed-file-types.service.ts | 29 ++++++++ server/src/nest/files/files.bridge.ts | 24 ------ server/src/nest/files/files.controller.ts | 65 ++++++++++------- server/src/nest/files/files.module.ts | 12 ++- server/src/nest/journey/journey.controller.ts | 62 +++++++++------- server/src/nest/journey/journey.module.ts | 12 ++- .../nest/llm-parse/llm-local.controller.ts | 3 +- server/src/nest/llm-parse/llm-local.dto.ts | 10 +++ server/src/nest/oauth/oauth-api.controller.ts | 8 +- server/src/nest/oauth/oauth.dto.ts | 17 +++++ .../contributions/plugin-routes.controller.ts | 3 +- .../plugin-user-settings.controller.ts | 3 +- server/src/nest/plugins/plugins.controller.ts | 15 ++-- server/src/nest/plugins/plugins.dto.ts | 73 +++++++++++++++++++ .../reservation-import.controller.ts | 11 +-- .../reservation-import.dto.ts | 29 ++++++++ server/src/nest/tags/tags.controller.ts | 12 ++- server/src/nest/tags/tags.dto.ts | 24 ++++++ server/tests/e2e/journey.e2e.test.ts | 13 ++++ server/tests/helpers/mcp-harness.ts | 20 ++++- server/tests/helpers/mcp-test-controllers.ts | 27 ++++++- server/tests/helpers/plugin-host.ts | 21 ++++-- .../integration/memories-synology.test.ts | 2 +- .../integration/plugins/supervisor.test.ts | 11 ++- server/tests/setup.ts | 20 +++-- .../tests/unit/db/migration-hygiene.test.ts | 6 +- server/tests/unit/mcp/tools-transit.test.ts | 6 +- ...admin-packing-templates.controller.test.ts | 14 +++- .../tests/unit/nest/admin.controller.test.ts | 4 +- .../nest/admin.version-notification.test.ts | 9 ++- server/tests/unit/nest/auth.service.test.ts | 2 +- .../reservation-import.controller.test.ts | 12 +-- .../unit/nest/categories.controller.test.ts | 2 +- server/tests/unit/nest/files.service.test.ts | 11 ++- .../unit/nest/memories.controller.test.ts | 25 ++++++- .../tests/unit/nest/oauth.controller.test.ts | 5 +- .../unit/nest/place-enrichment.brand.test.ts | 4 +- .../nest/place-enrichment.description.test.ts | 5 +- .../tests/unit/nest/tags.controller.test.ts | 9 ++- .../unit/nest/trip-read-model.service.test.ts | 15 +++- server/tests/unit/nest/trips.service.test.ts | 24 +++++- server/tests/unit/packing/packing.rpc.test.ts | 7 +- .../unit/plugins/plugins-service.test.ts | 6 +- .../unit/reservations/bookings.rpc.test.ts | 6 +- .../tests/unit/services/airtrailSync.test.ts | 11 ++- server/tests/unit/tags/tags.rpc.test.ts | 9 +++ shared/src/i18n/ar/admin.ts | 1 + shared/src/i18n/br/admin.ts | 1 + shared/src/i18n/ca/admin.ts | 1 + shared/src/i18n/cs/admin.ts | 1 + shared/src/i18n/de/admin.ts | 1 + shared/src/i18n/en/admin.ts | 1 + shared/src/i18n/es/admin.ts | 1 + shared/src/i18n/fr/admin.ts | 1 + shared/src/i18n/gr/admin.ts | 1 + shared/src/i18n/hu/admin.ts | 1 + shared/src/i18n/id/admin.ts | 1 + shared/src/i18n/it/admin.ts | 1 + shared/src/i18n/ja/admin.ts | 1 + shared/src/i18n/ko/admin.ts | 1 + shared/src/i18n/nl/admin.ts | 1 + shared/src/i18n/pl/admin.ts | 1 + shared/src/i18n/ru/admin.ts | 1 + shared/src/i18n/sv/admin.ts | 1 + shared/src/i18n/tr/admin.ts | 1 + shared/src/i18n/uk/admin.ts | 1 + shared/src/i18n/vi/admin.ts | 1 + shared/src/i18n/zh-TW/admin.ts | 1 + shared/src/i18n/zh/admin.ts | 1 + 74 files changed, 620 insertions(+), 196 deletions(-) create mode 100644 server/src/nest/files/allowed-file-types.module.ts create mode 100644 server/src/nest/files/allowed-file-types.service.ts delete mode 100644 server/src/nest/files/files.bridge.ts create mode 100644 server/src/nest/llm-parse/llm-local.dto.ts create mode 100644 server/src/nest/oauth/oauth.dto.ts create mode 100644 server/src/nest/plugins/plugins.dto.ts create mode 100644 server/src/nest/reservation-import/reservation-import.dto.ts create mode 100644 server/src/nest/tags/tags.dto.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b86a96344..6785e180a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -154,6 +154,13 @@ jobs: - name: Typecheck run: cd server && npm run typecheck + - name: Typecheck tests + # tests/ is outside the build tsconfig, so a service that grows a + # constructor parameter does not break its hand-wired call sites at + # compile time: the argument arrives as undefined and throws at runtime, + # if anything reads it at all. This is the step that catches it. + run: cd server && npm run typecheck:tests + - name: Lint run: cd server && npm run lint:check diff --git a/client/src/components/Plugins/PluginFrame.tsx b/client/src/components/Plugins/PluginFrame.tsx index 45df856ed..c97dde6fd 100644 --- a/client/src/components/Plugins/PluginFrame.tsx +++ b/client/src/components/Plugins/PluginFrame.tsx @@ -219,7 +219,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d // ids the plugin already had; never the httpOnly cookie); fully closing it // would require not running plugin client JS at all. const loadsRef = useRef(0) - const { locale } = useTranslation() + const { locale, t } = useTranslation() const navigate = useNavigate() const toast = useToast() // useToast returns a fresh object every render; keep it out of the effect deps @@ -243,6 +243,11 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d // The ref mirrors the state so the message handler can gate + answer without // side effects inside a setState updater (StrictMode runs updaters twice). const [confirmReq, setConfirmReq] = useState(null) + // The iframe document can fail to load on its own: a plugin uninstalled in + // another tab, a frame route the host no longer serves, a network blip on a + // lazily loaded frame. Without an onError the element just stays blank at full + // height, which reads as a hung app rather than as a plugin that is gone. + const [loadFailed, setLoadFailed] = useState(false) const confirmReqRef = useRef(null) // geolocation:read gate — the feed only flags plugins whose grant is recorded. // Read via ref (like toastRef) so the bridge effect doesn't re-run on store churn. @@ -593,14 +598,24 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d // Deliver the context as soon as the document is parsed (the plugin sets up its // message listener during parse), closing the trek:ready race so the theme is // right on first paint. A 2nd load is a self-navigation — don't bridge to it. - onLoad={() => { loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }} + onLoad={() => { setLoadFailed(false); loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }} + onError={() => setLoadFailed(true)} sandbox="allow-scripts allow-forms" referrerPolicy="no-referrer" loading="lazy" title={title || pluginId} className={className} - style={{ width: '100%', height: fill ? '100%' : height ?? '100%', border: 0 }} + style={{ width: '100%', height: fill ? '100%' : height ?? '100%', border: 0, display: loadFailed ? 'none' : undefined }} /> + {loadFailed && ( +
+ {t('plugins.frameLoadFailed')} +
+ )} answerConfirm(false)} diff --git a/server/package.json b/server/package.json index 5409f6864..65f321484 100644 --- a/server/package.json +++ b/server/package.json @@ -9,6 +9,7 @@ "start:prod": "node --require tsconfig-paths/register dist/index.js", "reset-admin": "node reset-admin.js", "typecheck": "tsc --noEmit", + "typecheck:tests": "tsc -p tsconfig.tests.json --noEmit", "gen:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts", "check:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts --check", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", diff --git a/server/src/nest/common/body-contract-allow-list.ts b/server/src/nest/common/body-contract-allow-list.ts index c87720420..82f846e19 100644 --- a/server/src/nest/common/body-contract-allow-list.ts +++ b/server/src/nest/common/body-contract-allow-list.ts @@ -3,26 +3,16 @@ * class — grandfathered when the fail-closed boot gate landed * (validate-body-contracts.ts). Entries are `ControllerClass.methodName`. * - * RATCHET ONLY: never add an entry. When a domain migrates, wrap its - * @trek/shared schema in a colocated .dto.ts and DELETE the routes - * here — the gate throws on stale entries, so removal is enforced too. + * EMPTY, and it stays that way. Every mutation handler in the tree now types + * its body with a createZodDto class, so the gate has nothing to forgive. It + * still runs: a new handler that reads @Body() without a contract refuses the + * boot rather than shipping unvalidated. Adding an entry here to get past that + * is not the fix. + * + * The last seventeen came off in the migration remainder. Several of their + * contracts describe rather than constrain, which is deliberate and documented + * at each DTO: those handlers own rejections the pipe would otherwise pre-empt, + * including two that answer 503 before ever looking at the body and one that + * answers `{ route: null }` at 200 for anything it dislikes. */ -export const BODY_CONTRACT_ALLOW_LIST: string[] = [ - 'LlmLocalController.pull', - 'OauthApiController.authorize', - 'OauthApiController.createClient', - 'PluginRoutesController.route', - 'PluginUserSettingsController.update', - 'PluginsController.activate', - 'PluginsController.install', - 'PluginsController.link', - 'PluginsController.retrust', - 'PluginsController.setEgressHosts', - 'PluginsController.uninstall', - 'PluginsController.updateConfig', - 'ReservationImportController.confirm', - 'ReservationImportController.preview', - 'ReservationImportController.previewAsync', - 'TagsController.create', - 'TagsController.update', -]; +export const BODY_CONTRACT_ALLOW_LIST: string[] = []; diff --git a/server/src/nest/files/allowed-file-types.module.ts b/server/src/nest/files/allowed-file-types.module.ts new file mode 100644 index 000000000..3778fe183 --- /dev/null +++ b/server/src/nest/files/allowed-file-types.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AllowedFileTypesService } from './allowed-file-types.service'; + +/** + * A leaf so both upload paths can reach the extension list without importing + * each other's domain. DatabaseModule is @Global, so this imports nothing. + */ +@Module({ + providers: [AllowedFileTypesService], + exports: [AllowedFileTypesService], +}) +export class AllowedFileTypesModule {} diff --git a/server/src/nest/files/allowed-file-types.service.ts b/server/src/nest/files/allowed-file-types.service.ts new file mode 100644 index 000000000..224df2948 --- /dev/null +++ b/server/src/nest/files/allowed-file-types.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { DEFAULT_ALLOWED_EXTENSIONS } from './files.constants'; + +/** + * The operator's allowed-extension list, on its own. + * + * Split out of FilesService so the multer factories can inject it without + * pulling the whole files domain into JourneyModule's injector. It is the only + * thing an upload's fileFilter needs from the container, and it is a live read: + * an admin changing the list in settings applies to the next upload, with no + * invalidation wiring. + */ +@Injectable() +export class AllowedFileTypesService { + constructor(private readonly db: DatabaseService) {} + + /** Comma-separated, as the admin panel stores it. `*` means anything. */ + get(): string { + try { + const row = this.db.get<{ value: string }>( + "SELECT value FROM app_settings WHERE key = 'allowed_file_types'", + ); + return row?.value || DEFAULT_ALLOWED_EXTENSIONS; + } catch { + return DEFAULT_ALLOWED_EXTENSIONS; + } + } +} diff --git a/server/src/nest/files/files.bridge.ts b/server/src/nest/files/files.bridge.ts deleted file mode 100644 index 45bc1d9c8..000000000 --- a/server/src/nest/files/files.bridge.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { db } from '../../db/database'; -import { DatabaseService } from '../database/database.service'; -import { RealtimeService } from '../realtime/realtime.service'; -import { FilesService } from './files.service'; -import { PermissionsService } from '../permissions/permissions.service'; -import { EphemeralTokenService } from '../auth/ephemeral-token.service'; - -/** - * Non-Nest entry point for the files domain. Unlike todo.bridge.ts this does - * NOT serve a legacy MCP registrar (files has no MCP surface) — it exists only - * because multer interceptor options are module-scope object literals (the - * UPLOAD configs in files.controller.ts and journey.controller.ts are built - * before any DI container exists), yet their fileFilter closures need the - * request-time app_settings read. Everything else injects FilesService. - * Delete this file if uploads ever move to MulterModule.registerAsync. - * - * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto - * the shared better-sqlite3 singleton. - */ -const files = new FilesService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService(), new EphemeralTokenService()); - -export function getAllowedExtensions(): string { - return files.getAllowedExtensions(); -} diff --git a/server/src/nest/files/files.controller.ts b/server/src/nest/files/files.controller.ts index a4d805b4c..38caecebb 100644 --- a/server/src/nest/files/files.controller.ts +++ b/server/src/nest/files/files.controller.ts @@ -30,34 +30,45 @@ import { TripAccessGuard } from '../permissions/trip-access.guard'; import type { TripAccess } from '../database/database.service'; import { Trip } from '../permissions/trip.decorator'; import { MAX_FILE_SIZE, MAX_VIDEO_SIZE, BLOCKED_EXTENSIONS, filesDir, isVideoExtension } from './files.constants'; -import { getAllowedExtensions } from './files.bridge'; import { FileUploadDto, FileUpdateDto, FileLinkDto } from './files.dto'; +import { AllowedFileTypesService } from './allowed-file-types.service'; -const UPLOAD = { - storage: diskStorage({ - destination: (_req, _file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir); }, - filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`), - }), - // Allow up to the video cap; non-video files are still held to MAX_FILE_SIZE by - // the per-type guard in the upload handler (#823). - limits: { fileSize: MAX_VIDEO_SIZE }, - defParamCharset: 'utf8', // parity with legacy routes/files.ts — preserve non-ASCII original filenames - fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { - const ext = path.extname(file.originalname).toLowerCase(); - const reject = () => { - // i18n key — the client resolves it via t() (see translateApiError). - const err: Error & { statusCode?: number } = new Error('files.uploadErrorType'); - err.statusCode = 400; - cb(err, false); - }; - if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject(); - const allowed = getAllowedExtensions().split(',').map((e) => e.trim().toLowerCase()); - const fileExt = ext.replace('.', ''); - // Video is accepted as media regardless of the admin doc-types allowlist (#823). - if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true); - reject(); - }, -}; +/** + * Trip-file upload options, built from the container. + * + * A factory rather than a module-scope literal because the fileFilter reads the + * operator's allowed-extension list at request time, and that list lives in the + * database. It used to reach it through files.bridge.ts, which built a second + * FilesService outside the injector; MulterModule.registerAsync gives the + * closure the real one. + */ +export function buildFilesUploadOptions(allowedTypes: AllowedFileTypesService) { + return { + storage: diskStorage({ + destination: (_req, _file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir); }, + filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`), + }), + // Allow up to the video cap; non-video files are still held to MAX_FILE_SIZE by + // the per-type guard in the upload handler (#823). + limits: { fileSize: MAX_VIDEO_SIZE }, + defParamCharset: 'utf8', // parity with legacy routes/files.ts — preserve non-ASCII original filenames + fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { + const ext = path.extname(file.originalname).toLowerCase(); + const reject = () => { + // i18n key — the client resolves it via t() (see translateApiError). + const err: Error & { statusCode?: number } = new Error('files.uploadErrorType'); + err.statusCode = 400; + cb(err, false); + }; + if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject(); + const allowed = allowedTypes.get().split(',').map((e) => e.trim().toLowerCase()); + const fileExt = ext.replace('.', ''); + // Video is accepted as media regardless of the admin doc-types allowlist (#823). + if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true); + reject(); + }, + }; +} /** * /api/trips/:tripId/files — trip file manager (upload, metadata, starring, @@ -105,7 +116,7 @@ export class FilesController { } @Post() - @UseInterceptors(FileInterceptor('file', UPLOAD)) + @UseInterceptors(FileInterceptor('file')) upload( @CurrentUser() user: User, @Param('tripId') tripId: string, diff --git a/server/src/nest/files/files.module.ts b/server/src/nest/files/files.module.ts index 12946c0a0..1788267c2 100644 --- a/server/src/nest/files/files.module.ts +++ b/server/src/nest/files/files.module.ts @@ -8,9 +8,19 @@ import { RealtimeModule } from '../realtime/realtime.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { AppConfigModule } from '../app-config/app-config.module'; import { EphemeralTokenModule } from '../auth/ephemeral-token.module'; +import { MulterModule } from '@nestjs/platform-express'; +import { AllowedFileTypesModule } from './allowed-file-types.module'; +import { AllowedFileTypesService } from './allowed-file-types.service'; +import { buildFilesUploadOptions } from './files.controller'; @Module({ - imports: [EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule], + imports: [ + MulterModule.registerAsync({ + imports: [AllowedFileTypesModule], + inject: [AllowedFileTypesService], + useFactory: (allowedTypes: AllowedFileTypesService) => buildFilesUploadOptions(allowedTypes), + }), + EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule], controllers: [FilesController, FilesDownloadController], providers: [FilesService, FilesRpc], exports: [FilesService], diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index 34b56357c..200ab0352 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -34,31 +34,41 @@ import { JourneyReorderEntriesDto, JourneyShareLinkDto, JourneyUpdateDto, } from './journey.dto'; import { isVideoMime, isVideoExtension, MAX_VIDEO_SIZE } from '../files/files.constants'; -import { getAllowedExtensions } from '../files/files.bridge'; +import { AllowedFileTypesService } from '../files/allowed-file-types.service'; const uploadsBase = path.join(__dirname, '../../../uploads/journey'); -const IMAGE_UPLOAD = { - storage: diskStorage({ - destination: (_req, _file, cb) => { if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true }); cb(null, uploadsBase); }, - filename: (_req, file, cb) => cb(null, `${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase() || '.jpg'}`), - }), - limits: { fileSize: 20 * 1024 * 1024 }, - fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { - if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) { - const err: Error & { statusCode?: number } = new Error('Only image files are allowed'); - err.statusCode = 400; - return cb(err, false); - } - const ext = path.extname(file.originalname).toLowerCase().replace('.', ''); - const allowed = getAllowedExtensions().split(',').map((e) => e.trim().toLowerCase()); - if (!allowed.includes('*') && !allowed.includes(ext)) { - const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`); - err.statusCode = 400; - return cb(err, false); - } - cb(null, true); - }, -}; +/** + * Journey image upload options, built from the container. + * + * Same reason as the trip-file factory: the fileFilter reads the operator's + * allowed-extension list at request time. Note this config carries NO + * defParamCharset, unlike the trip-file one; that difference is deliberate and + * predates the move, so the two are not folded into one shared object. + */ +export function buildJourneyImageUploadOptions(allowedTypes: AllowedFileTypesService) { + return { + storage: diskStorage({ + destination: (_req, _file, cb) => { if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true }); cb(null, uploadsBase); }, + filename: (_req, file, cb) => cb(null, `${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase() || '.jpg'}`), + }), + limits: { fileSize: 20 * 1024 * 1024 }, + fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { + if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) { + const err: Error & { statusCode?: number } = new Error('Only image files are allowed'); + err.statusCode = 400; + return cb(err, false); + } + const ext = path.extname(file.originalname).toLowerCase().replace('.', ''); + const allowed = allowedTypes.get().split(',').map((e) => e.trim().toLowerCase()); + if (!allowed.includes('*') && !allowed.includes(ext)) { + const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`); + err.statusCode = 400; + return cb(err, false); + } + cb(null, true); + }, + }; +} // Gallery video upload (#823): one video plus an optional client-captured poster // image, written to the same uploads/journey store. Larger cap than images since @@ -161,7 +171,7 @@ export class JourneyController { } @Post('entries/:entryId/photos') - @UseInterceptors(FilesInterceptor('photos', undefined, IMAGE_UPLOAD)) + @UseInterceptors(FilesInterceptor('photos')) async uploadEntryPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, @Body() body: JourneyEntryPhotoUploadDto) { if (!files?.length) { throw new HttpException({ error: 'No files uploaded' }, 400); @@ -257,7 +267,7 @@ export class JourneyController { // ── Gallery (prefix /:id/gallery — before /:id) ───────────────────────── @Post(':id/gallery/photos') - @UseInterceptors(FilesInterceptor('photos', undefined, IMAGE_UPLOAD)) + @UseInterceptors(FilesInterceptor('photos')) uploadGalleryPhotos(@CurrentUser() user: User, @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[] | undefined) { if (!files?.length) { throw new HttpException({ error: 'No files uploaded' }, 400); @@ -360,7 +370,7 @@ export class JourneyController { @Post(':id/cover') @HttpCode(200) // Express answers cover with res.json (200). - @UseInterceptors(FileInterceptor('cover', IMAGE_UPLOAD)) + @UseInterceptors(FileInterceptor('cover')) cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined) { if (!file) { throw new HttpException({ error: 'No file uploaded' }, 400); diff --git a/server/src/nest/journey/journey.module.ts b/server/src/nest/journey/journey.module.ts index da1b12ce4..e6b7ab86e 100644 --- a/server/src/nest/journey/journey.module.ts +++ b/server/src/nest/journey/journey.module.ts @@ -7,10 +7,20 @@ import { MemoriesModule } from '../memories/memories.module'; import { JourneyDomainModule } from './journey-domain.module'; import { JourneyMcp } from './journey.mcp'; import { AuthModule } from '../auth/auth.module'; +import { MulterModule } from '@nestjs/platform-express'; +import { AllowedFileTypesModule } from '../files/allowed-file-types.module'; +import { AllowedFileTypesService } from '../files/allowed-file-types.service'; +import { buildJourneyImageUploadOptions } from './journey.controller'; @Module({ // MemoriesModule: the journey gallery streams provider assets and uploads to Immich. - imports: [AuthModule, AddonsModule, MemoriesModule, JourneyDomainModule], + imports: [ + MulterModule.registerAsync({ + imports: [AllowedFileTypesModule], + inject: [AllowedFileTypesService], + useFactory: (allowedTypes: AllowedFileTypesService) => buildJourneyImageUploadOptions(allowedTypes), + }), + AuthModule, AddonsModule, MemoriesModule, JourneyDomainModule], controllers: [JourneyController, JourneyPublicController], providers: [JourneyService, JourneyMcp], }) diff --git a/server/src/nest/llm-parse/llm-local.controller.ts b/server/src/nest/llm-parse/llm-local.controller.ts index 10b3886b1..df65828a6 100644 --- a/server/src/nest/llm-parse/llm-local.controller.ts +++ b/server/src/nest/llm-parse/llm-local.controller.ts @@ -3,6 +3,7 @@ import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AdminGuard } from '../auth/admin.guard'; import { LlmLocalService } from './llm-local.service'; +import { LlmLocalPullDto } from './llm-local.dto'; /** * Admin-only management of a local LLM server (Ollama): list installed models and @@ -24,7 +25,7 @@ export class LlmLocalController { * response body to render a progress bar. Uses @Res() to stream manually. */ @Post('pull') - async pull(@Body() body: { baseUrl?: string; model?: string }, @Res() res: Response): Promise { + async pull(@Body() body: LlmLocalPullDto, @Res() res: Response): Promise { const stream = await this.local.pull(body?.baseUrl, body?.model ?? ''); res.status(200); res.setHeader('Content-Type', 'application/x-ndjson'); diff --git a/server/src/nest/llm-parse/llm-local.dto.ts b/server/src/nest/llm-parse/llm-local.dto.ts new file mode 100644 index 000000000..d8082e1df --- /dev/null +++ b/server/src/nest/llm-parse/llm-local.dto.ts @@ -0,0 +1,10 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +/** + * The Ollama pull body. Both fields are optional because the service supplies + * its own defaults for a missing baseUrl and reports an empty model itself. + */ +export class LlmLocalPullDto extends createZodDto( + z.looseObject({ baseUrl: z.string().optional(), model: z.string().optional() }), +) {} diff --git a/server/src/nest/oauth/oauth-api.controller.ts b/server/src/nest/oauth/oauth-api.controller.ts index 96727dde7..2a406998a 100644 --- a/server/src/nest/oauth/oauth-api.controller.ts +++ b/server/src/nest/oauth/oauth-api.controller.ts @@ -9,6 +9,7 @@ import { CurrentUser } from '../auth/current-user.decorator'; import { getClientIp } from '../audit/client-ip'; import type { User } from '../../types'; import type { AuthorizeParams } from './oauth.service'; +import { OauthClientCreateDto, OauthConsentDto } from './oauth.dto'; const MIN = 60_000; @@ -67,10 +68,7 @@ export class OauthApiController { @Post('authorize') @HttpCode(200) // Express answers consent with res.json (200), not the POST-default 201. @UseGuards(CookieAuthGuard) - authorize(@CurrentUser() user: User, @Body() body: { - client_id: string; redirect_uri: string; scope: string; state?: string; - code_challenge: string; code_challenge_method: string; approved: boolean; resource?: string; - }, @Req() req: Request) { + authorize(@CurrentUser() user: User, @Body() body: OauthConsentDto, @Req() req: Request) { const ip = getClientIp(req); if (!this.oauth.mcpEnabled()) { throw new HttpException({ error: 'MCP is not enabled' }, 403); @@ -126,7 +124,7 @@ export class OauthApiController { @Post('clients') @HttpCode(201) @UseGuards(CookieAuthGuard) - createClient(@CurrentUser() user: User, @Body() body: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }, @Req() req: Request) { + createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) { this.requireMcp403(); const result = this.oauth.createOAuthClient(user.id, body.name, body.redirect_uris ?? [], body.allowed_scopes, getClientIp(req), { allowsClientCredentials: body.allows_client_credentials }); if (result.error) { diff --git a/server/src/nest/oauth/oauth.dto.ts b/server/src/nest/oauth/oauth.dto.ts new file mode 100644 index 000000000..cd8ffb4b8 --- /dev/null +++ b/server/src/nest/oauth/oauth.dto.ts @@ -0,0 +1,17 @@ +import { createZodDto } from 'nestjs-zod'; +import { oauthClientCreateRequestSchema, oauthConsentRequestSchema } from '@trek/shared'; + +/** + * Server-side createZodDto wrappers over the @trek/shared OAuth contracts. + * + * Both schemas already described these two bodies exactly; they were written + * with the contracts and then never wired to the handlers, which declared the + * same shape inline as an anonymous type. The inline types are gone now, so the + * wire shape has one definition instead of two that could drift. + * + * Neither handler owns a bespoke required-field message: consent redirects with + * `error=access_denied` on a rejection, and client creation surfaces whatever + * the service returns. So nothing user-visible moves in front of the pipe here. + */ +export class OauthConsentDto extends createZodDto(oauthConsentRequestSchema) {} +export class OauthClientCreateDto extends createZodDto(oauthClientCreateRequestSchema) {} diff --git a/server/src/nest/plugins/contributions/plugin-routes.controller.ts b/server/src/nest/plugins/contributions/plugin-routes.controller.ts index 352b145c9..6f19cc47f 100644 --- a/server/src/nest/plugins/contributions/plugin-routes.controller.ts +++ b/server/src/nest/plugins/contributions/plugin-routes.controller.ts @@ -6,6 +6,7 @@ import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { pluginsEnabled } from '../kill-switch'; import { PluginHooks } from '../plugin-hooks.service'; import { stripEmoji } from '../text-sanitize'; +import { PluginRouteDto } from '../plugins.dto'; /** * POST /api/plugin-routes/:pluginId/:profileId — ask ONE routeProvider plugin to @@ -158,7 +159,7 @@ export class PluginRoutesController { async route( @Param('pluginId') pluginId: string, @Param('profileId') profileId: string, - @Body() body: Record | undefined, + @Body() body: PluginRouteDto, @Req() req: Request & { user?: { id: number } }, ): Promise<{ route: PluginRouteOut | null }> { if (!pluginsEnabled()) return { route: null }; diff --git a/server/src/nest/plugins/plugin-user-settings.controller.ts b/server/src/nest/plugins/plugin-user-settings.controller.ts index 0cc284c3a..c83e8a8fa 100644 --- a/server/src/nest/plugins/plugin-user-settings.controller.ts +++ b/server/src/nest/plugins/plugin-user-settings.controller.ts @@ -5,6 +5,7 @@ import { pluginsEnabled } from './kill-switch'; import { PluginsService } from './plugins.service'; import { PluginRuntimeService } from './plugin-runtime.service'; import { DatabaseService } from '../database/database.service'; +import { PluginUserSettingsUpdateDto } from './plugins.dto'; /** * GET/POST /api/plugin-settings/:id — a USER's own `scope:'user'` settings for a @@ -74,7 +75,7 @@ export class PluginUserSettingsController { @Post(':id') update( @Param('id') id: string, - @Body() body: { config?: Record }, + @Body() body: PluginUserSettingsUpdateDto, @Req() req: Request & { user?: { id: number } }, ): { config: Record } { const userId = req.user?.id; diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index 7813fb784..94a3fc134 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -11,6 +11,7 @@ import { CurrentUser } from '../auth/current-user.decorator'; import { getClientIp } from '../audit/client-ip'; import { pluginsEnabled } from './kill-switch'; import { devLinkEnabled } from './dev-link'; +import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto } from './plugins.dto'; /** * Flatten a registry/install failure into the error envelope — CARRYING THE CODE. @@ -70,7 +71,7 @@ export class PluginsController { @Post('install') @HttpCode(200) - async install(@Body() body: { id?: string; version?: string; constraint?: string; withDependencies?: boolean }) { + async install(@Body() body: PluginInstallDto) { if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); if (!body?.id) throw new HttpException({ error: 'id is required' }, 400); try { @@ -104,7 +105,7 @@ export class PluginsController { */ @Post('link') @HttpCode(200) - async link(@Body() body: { path?: string }) { + async link(@Body() body: PluginLinkDto) { if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); if (!devLinkEnabled()) throw new HttpException({ error: 'Dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)' }, 403); const dir = body?.path?.trim(); @@ -122,7 +123,7 @@ export class PluginsController { } @Put(':id/config') - updateConfig(@Param('id') id: string, @Body() body: Record) { + updateConfig(@Param('id') id: string, @Body() body: PluginConfigDto) { return { config: this.plugins.updateInstanceConfig(id, body || {}) }; } @@ -138,7 +139,7 @@ export class PluginsController { } @Put(':id/egress-hosts') - async setEgressHosts(@Param('id') id: string, @Body() body: { hosts?: unknown } = {}) { + async setEgressHosts(@Param('id') id: string, @Body() body: PluginEgressHostsDto) { if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); const hosts = Array.isArray(body.hosts) ? body.hosts.map(String) : []; try { @@ -150,7 +151,7 @@ export class PluginsController { @Post(':id/activate') @HttpCode(200) - async activate(@Param('id') id: string, @Body() body: { consent?: boolean } = {}) { + async activate(@Param('id') id: string, @Body() body: PluginActivateDto) { if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); try { await this.runtime.activate(id, !!body?.consent); @@ -231,7 +232,7 @@ export class PluginsController { @HttpCode(200) async retrust( @Param('id') id: string, - @Body() body: { version?: string; publicKey?: string }, + @Body() body: PluginRetrustDto, @CurrentUser() user: { id: number }, @Req() req: Request, ) { @@ -247,7 +248,7 @@ export class PluginsController { @Post(':id/uninstall') @HttpCode(200) - async uninstall(@Param('id') id: string, @Body() body: { deleteData?: boolean }) { + async uninstall(@Param('id') id: string, @Body() body: PluginUninstallDto) { await this.runtime.uninstall(id, !!body?.deleteData); return { status: 'uninstalled' }; } diff --git a/server/src/nest/plugins/plugins.dto.ts b/server/src/nest/plugins/plugins.dto.ts new file mode 100644 index 000000000..c3f123d35 --- /dev/null +++ b/server/src/nest/plugins/plugins.dto.ts @@ -0,0 +1,73 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +/** + * Body contracts for the plugin surfaces, written to DESCRIBE rather than to + * tighten (the journey-contract doctrine, #1842). + * + * Every one of these handlers already owns its own rejection, and several of + * them answer something other than 400 for a body they dislike: activate and + * install answer 503 when plugins are disabled, and they check that BEFORE + * looking at the body. A schema that rejected first would move a 503 to a 400 + * for an operator whose server has plugins off. So the fields stay optional and + * the objects stay loose: the DTO pins the wire TYPES, and the handler keeps + * every decision it already made, in the order it already made it. + */ + +/** `id is required` stays the handler's answer, so id is optional here. */ +export class PluginInstallDto extends createZodDto( + z.looseObject({ + id: z.string().optional(), + version: z.string().optional(), + constraint: z.string().optional(), + withDependencies: z.boolean().optional(), + }), +) {} + +/** `path is required` likewise, and dev-link answers 403 before reading it. */ +export class PluginLinkDto extends createZodDto( + z.looseObject({ path: z.string().optional() }), +) {} + +export class PluginActivateDto extends createZodDto( + z.looseObject({ consent: z.boolean().optional() }), +) {} + +export class PluginUninstallDto extends createZodDto( + z.looseObject({ deleteData: z.boolean().optional() }), +) {} + +export class PluginRetrustDto extends createZodDto( + z.looseObject({ version: z.string().optional(), publicKey: z.string().optional() }), +) {} + +/** + * `hosts` is deliberately unknown, not `string[]`. + * + * The handler reads `Array.isArray(body.hosts) ? body.hosts.map(String) : []`, + * which means an omitted or non-array `hosts` CLEARS the operator egress list. + * That is the documented way to reset it, so a schema demanding an array would + * not tighten validation, it would remove a working admin action. + */ +export class PluginEgressHostsDto extends createZodDto( + z.looseObject({ hosts: z.unknown().optional() }), +) {} + +/** + * Plugin instance config is defined by the plugin's own manifest, not by TREK, + * so there is no shape to assert here beyond "a JSON object". + */ +export class PluginConfigDto extends createZodDto(z.looseObject({})) {} + +export class PluginUserSettingsUpdateDto extends createZodDto( + z.looseObject({ config: z.record(z.string(), z.unknown()).optional() }), +) {} + +/** + * The route hook answers `{ route: null }` at 200 for every input it dislikes: + * a missing trip, a bad profile, unusable waypoints, a slow provider. The client + * draws straight lines on a null and shows nothing on a 400, so a schema that + * rejected a malformed body would turn a graceful fallback into a visible error. + * Loose and fully optional on purpose. + */ +export class PluginRouteDto extends createZodDto(z.looseObject({})) {} diff --git a/server/src/nest/reservation-import/reservation-import.controller.ts b/server/src/nest/reservation-import/reservation-import.controller.ts index 9e95f0533..2f564c83f 100644 --- a/server/src/nest/reservation-import/reservation-import.controller.ts +++ b/server/src/nest/reservation-import/reservation-import.controller.ts @@ -26,6 +26,7 @@ import { AirtrailImportDto } from '../integrations/airtrail.dto'; import type { AirtrailImportResult } from '@trek/shared'; import { bookingImportModeSchema } from '@trek/shared'; import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode } from '@trek/shared'; +import { BookingImportConfirmDto, BookingImportPreviewDto } from './reservation-import.dto'; const ACCEPTED_EXTS = new Set(['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']); const MAX_FILE_BYTES = 10 * 1024 * 1024; @@ -127,9 +128,9 @@ export class ReservationImportController { @CurrentUser() user: User, @Param('tripId') tripId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, - @Body('mode') rawMode?: string, + @Body() body: BookingImportPreviewDto, ): Promise { - const mode = this.validateImport(tripId, user, files, rawMode); + const mode = this.validateImport(tripId, user, files, body?.mode); return this.bookingImport.preview(files!, mode, user.id); } @@ -147,9 +148,9 @@ export class ReservationImportController { @CurrentUser() user: User, @Param('tripId') tripId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, - @Body('mode') rawMode?: string, + @Body() body: BookingImportPreviewDto, ): Promise<{ jobId: string }> { - const mode = this.validateImport(tripId, user, files, rawMode); + const mode = this.validateImport(tripId, user, files, body?.mode); const jobId = this.importJobs.start(tripId, files!, mode, user.id); return { jobId }; } @@ -175,7 +176,7 @@ export class ReservationImportController { async confirm( @CurrentUser() user: User, @Param('tripId') tripId: string, - @Body() body: { items?: BookingImportPreviewItem[] }, + @Body() body: BookingImportConfirmDto, @Headers('x-socket-id') socketId?: string, ): Promise { diff --git a/server/src/nest/reservation-import/reservation-import.dto.ts b/server/src/nest/reservation-import/reservation-import.dto.ts new file mode 100644 index 000000000..3a7e2e1eb --- /dev/null +++ b/server/src/nest/reservation-import/reservation-import.dto.ts @@ -0,0 +1,29 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; +import { bookingImportPreviewItemSchema } from '@trek/shared'; + +/** + * Server-side createZodDto wrappers for the booking-import bodies. + * + * Confirm validates each item against the shared contract but leaves `items` + * itself optional, which is narrower than `bookingImportConfirmRequestSchema` + * on purpose. That schema declares `.min(1)`, and the pipe reports a rejection + * as `field: message` — so adopting it verbatim would replace the endpoint's + * `{ error: 'items must be a non-empty array' }`. The handler keeps the check + * that owns the message, and the DTO adds what the handler never did: per-item + * shape validation. + * + * Preview and its async twin carry a multipart body, so every field arrives as a + * string and the real payload is the files. `mode` is described here for the + * type, and deliberately not narrowed to the mode enum: `validateImport` parses + * it with `bookingImportModeSchema` and owns the `Invalid mode` response. The + * object is not strict, because a multipart form may carry fields the browser + * adds and the handler ignores. + */ +export class BookingImportConfirmDto extends createZodDto( + z.object({ items: z.array(bookingImportPreviewItemSchema).optional() }), +) {} + +export class BookingImportPreviewDto extends createZodDto( + z.object({ mode: z.string().optional() }), +) {} diff --git a/server/src/nest/tags/tags.controller.ts b/server/src/nest/tags/tags.controller.ts index 83de5d912..b3cd7d780 100644 --- a/server/src/nest/tags/tags.controller.ts +++ b/server/src/nest/tags/tags.controller.ts @@ -4,6 +4,7 @@ import type { User } from '../../types'; import { TagsService } from './tags.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; +import { TagCreateDto, TagUpdateDto } from './tags.dto'; /** * /api/tags — per-user place-tag CRUD. @@ -25,11 +26,8 @@ export class TagsController { } @Post() - create( - @CurrentUser() user: User, - @Body('name') name?: string, - @Body('color') color?: string, - ): { tag: Tag } { + create(@CurrentUser() user: User, @Body() body: TagCreateDto): { tag: Tag } { + const { name, color } = body; if (!name) { throw new HttpException({ error: 'Tag name is required' }, 400); } @@ -40,9 +38,9 @@ export class TagsController { update( @CurrentUser() user: User, @Param('id') id: string, - @Body('name') name?: string, - @Body('color') color?: string, + @Body() body: TagUpdateDto, ): { tag: Tag } { + const { name, color } = body; if (!this.tags.getByIdAndUser(id, user.id)) { throw new HttpException({ error: 'Tag not found' }, 404); } diff --git a/server/src/nest/tags/tags.dto.ts b/server/src/nest/tags/tags.dto.ts new file mode 100644 index 000000000..a2b580b89 --- /dev/null +++ b/server/src/nest/tags/tags.dto.ts @@ -0,0 +1,24 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; +import { updateTagRequestSchema } from '@trek/shared'; + +/** + * Server-side createZodDto wrappers over the @trek/shared tag contracts. + * + * Create deliberately does NOT use `createTagRequestSchema` as written. That + * schema declares `name: z.string().min(1)`, and the pipe reports a rejection as + * `field: message` — so adopting it verbatim would replace the endpoint's + * `{ error: 'Tag name is required' }` with `name: ...`. Two tests pin that + * string, and more to the point it is what a client shows the user. + * + * So the DTO accepts the shape and leaves `name` optional, and the handler keeps + * the check that owns the message. The endpoint's actual contract is unchanged: + * a request without a name still gets 400, with the same body it always had. + * What the DTO does add is type and shape validation on everything else, which + * is what took this route off the allow-list. + */ +export class TagCreateDto extends createZodDto( + z.object({ name: z.string().optional(), color: z.string().optional() }), +) {} + +export class TagUpdateDto extends createZodDto(updateTagRequestSchema) {} diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index 01758fde0..b2bedf69f 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -132,6 +132,19 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => { expect(res.body).toEqual({ id: 9 }); }); + it('the image fileFilter is wired: a disallowed extension is rejected, not stored', async () => { + // The multer options are built by MulterModule.registerAsync now, and Nest + // injects MULTER_MODULE_OPTIONS with @Optional(): a token that fails to + // resolve is not a boot error, it silently falls back to defaults, which + // means no fileFilter and no size cap. This case is what turns that into a + // visible failure, since an unfiltered upload would answer 201. + const res = await request(server) + .post('/api/journeys/9/cover') + .set('Cookie', sessionCookie(1)) + .attach('cover', Buffer.from('MZ'), { filename: 'payload.exe', contentType: 'application/octet-stream' }); + expect(res.status).toBe(400); + }); + it('public journey 404 for an unknown token', async () => { sharesvc.getPublicJourney.mockReturnValueOnce(null); const res = await request(server).get('/api/public/journey/bad'); diff --git a/server/tests/helpers/mcp-harness.ts b/server/tests/helpers/mcp-harness.ts index 43f777890..3f2c2853a 100644 --- a/server/tests/helpers/mcp-harness.ts +++ b/server/tests/helpers/mcp-harness.ts @@ -43,7 +43,9 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise>; +type ToolCallContentResult = Extract; +type ToolTextContent = Extract; + /** Parse JSON from a callTool result (first text content item). */ -export function parseToolResult(result: Awaited>): unknown { - const text = result.content.find((c: { type: string }) => c.type === 'text') as { type: 'text'; text: string } | undefined; +export function parseToolResult(result: ToolCallResult): unknown { + // Our own McpServer always answers with the content shape, never the + // compatibility one, so pick that branch of the union. + const { content } = result as ToolCallContentResult; + const text = content.find((c): c is ToolTextContent => c.type === 'text'); if (!text) throw new Error('No text content in tool result'); return JSON.parse(text.text); } diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 4fa2aba61..ed3e80b31 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -57,6 +57,9 @@ import { JourneyShareService } from '../../src/nest/journey/journey-share.servic import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository'; import { UnsplashService } from '../../src/nest/unsplash/unsplash.service'; import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service'; +import { WebauthnConfigService } from '../../src/nest/auth/webauthn-config.service'; +import { TripMembershipService } from '../../src/nest/trip-membership/trip-membership.service'; +import { MailerService } from '../../src/nest/notifications/mailer/mailer.service'; import { CalendarService } from '../../src/nest/calendar/calendar.service'; import { AccommodationsService } from '../../src/nest/accommodations/accommodations.service'; import { AccommodationsMcp } from '../../src/nest/accommodations/accommodations.mcp'; @@ -79,7 +82,20 @@ import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.servi export function createMcpTestRegistry(): McpRegistry { const dbService = new DatabaseService(db); const permissionsService = new PermissionsService(dbService); - const authService = new AuthService(dbService, permissionsService, new AtlasService(dbService), new EphemeralTokenService()); + // Same argument list as auth.bridge.ts. AtlasService used to sit in third + // place; when getTravelStats moved onto AtlasService itself the edge was + // dropped and four collaborators took its place, but this call site kept the + // old shape, so `membership` and `webauthn` held the wrong objects and + // userCleanup/mailer/tokens were undefined. + const authService = new AuthService( + dbService, + permissionsService, + new TripMembershipService(dbService), + new WebauthnConfigService(dbService), + new UserCleanupService(dbService), + new MailerService(dbService), + new EphemeralTokenService(), + ); const realtimeService = new RealtimeService(); const queryHelpersService = new QueryHelpersService(dbService); const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService); @@ -88,7 +104,10 @@ export function createMcpTestRegistry(): McpRegistry { const todoService = new TodoService(dbService, permissionsService, realtimeService); const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub()); const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub()); - const mapsService = new MapsService(dbService); + // Exactly one instance, shared by maps, places and share: its stampede guard + // and its on-disk set only work if all three readers see the same maps. + const placePhotoCache = new PlacePhotoCacheService(dbService, new RuntimeEnvService()); + const mapsService = new MapsService(dbService, placePhotoCache); const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService)); // The last three were previously omitted, which left them `undefined` at // runtime — silently fine while nothing called them, a TypeError the moment @@ -97,7 +116,7 @@ export function createMcpTestRegistry(): McpRegistry { const placesService = new PlacesService( dbService, permissionsService, realtimeService, mapsService, queryHelpersService, new UnsplashService(dbService, new RuntimeEnvService()), - new PlacePhotoCacheService(dbService, new RuntimeEnvService()), + placePhotoCache, journeyDomain, ); const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub()); @@ -146,7 +165,7 @@ export function createMcpTestRegistry(): McpRegistry { new CollabMcp(collabService, authService, addonsService), new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService), new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService), - new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService), authService), + new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService), new MapsMcp(mapsService), new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain), new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub()), dbService, authService, addonsService), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 25839f99f..871431e37 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -64,6 +64,11 @@ import { CollectionsRpc } from '../../src/nest/collections/collections.rpc'; import { makeNotificationsService } from './notifications'; import { notificationsStub } from './notifications'; import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.service'; +import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service'; +import { UnsplashService } from '../../src/nest/unsplash/unsplash.service'; +import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service'; +import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository'; +import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service'; /** * Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests @@ -89,21 +94,21 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF const collab = new CollabService(dbs, permissions, realtime, notificationsStub()); const vacay = new VacayService(dbs, realtime, notificationsStub()); const days = new DaysService(dbs, permissions, realtime, queryHelpers); - const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs), queryHelpers); + const photoCache = new PlacePhotoCacheService(dbs, new RuntimeEnvService()); + const unsplash = new UnsplashService(dbs, new RuntimeEnvService()); + const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)); + const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs, photoCache), queryHelpers, unsplash, photoCache, journey); const collections = new CollectionsService(dbs, permissions, realtime, notificationsStub()); const atlas = new AtlasService(dbs); const dayNotes = new DayNotesService(dbs, permissions, realtime); - const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers); - const journey = new JourneyDomainService(dbs); - const membership = new TripMembershipService(dbs, permissions, realtime); + const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers, journey); + const membership = new TripMembershipService(dbs); const notifications = makeNotificationsService(dbs, realtime); const llmConfig = new LlmConfigResolver(new SettingsService(dbs), dbs, addons); const oauth = new PluginOAuthService(dbs); const accommodations = new AccommodationsService(dbs, permissions, realtime); - // Was 12 arguments for a 14-parameter constructor; unsplash and userCleanup were - // undefined here and tsconfig only typechecks src, so nothing said so. - const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, undefined as never); - const members = new TripMembersService(dbs, budget, undefined as never, permissions, realtime, notificationsStub()); + const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash); + const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs), permissions, realtime, notificationsStub()); const guards = new PluginGuards(dbs, permissions, addons); const registry = createTestPluginRegistry([ diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index 81004e28b..bfb3a2efd 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -8,7 +8,7 @@ * * No real HTTP calls are made. */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; import type { INestApplication } from '@nestjs/common'; diff --git a/server/tests/integration/plugins/supervisor.test.ts b/server/tests/integration/plugins/supervisor.test.ts index 6d2c935f6..936129684 100644 --- a/server/tests/integration/plugins/supervisor.test.ts +++ b/server/tests/integration/plugins/supervisor.test.ts @@ -16,6 +16,15 @@ import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc import { PluginDataDb } from '../../../src/nest/plugins/host/plugin-data.service'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc'; +import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service'; + +/** + * DbRpc also carries `settings.get`, which needs the per-user settings store. No + * fixture below calls it, and none of these plugins has a stored setting anyway, so + * the stub is the one read DbRpc makes, typed off the real signature and answering + * "nothing stored" exactly as the real service would. + */ +const stubUserSettings: Pick = { readOne: () => undefined }; let codeRoot: string; let dataRoot: string; @@ -38,7 +47,7 @@ function makeSupervisor(events: Array<{ topic: string; data: unknown }>, tuning: }; // Only db.* is exercised from a child here; the rest of the surface has its own // unit suites and would drag every domain service into this integration test. - return new PluginRpcHost(id, granted, deps, createTestPluginRegistry([new DbRpc()])); + return new PluginRpcHost(id, granted, deps, createTestPluginRegistry([new DbRpc(stubUserSettings as PluginUserSettingsService)])); }; const hooks: SupervisorHooks = { onEvent: (_id, topic, data) => events.push({ topic, data }), diff --git a/server/tests/setup.ts b/server/tests/setup.ts index 1451d303b..3aeef511c 100644 --- a/server/tests/setup.ts +++ b/server/tests/setup.ts @@ -3,6 +3,20 @@ // config.ts, database.ts, etc. pick them up at import time. (Importing from // 'vitest' itself is safe: it is externalized and pulls in no app modules.) import { afterEach } from 'vitest'; +// The one app module imported here, and it is deliberately a static import even +// though import declarations are hoisted above the process.env writes below. +// The chain is nominatim.client -> maps.helpers -> app-config, and its only +// load-time read of the environment is the UA constant's getAppUrl(), which +// looks at APP_URL, ALLOWED_ORIGINS and PORT. This file sets none of those, so +// nothing in the chain observes a variable before it is written; config.ts and +// database.ts are not on it. +// +// It also has to be static. A setup file is imported for its side effects, not +// awaited as a hook, so a deferred `import()` would still be resolving while the +// test file registers its own mocks: booking-import's kitinerary suite replaces +// app-config with a factory that has no getAppUrl, and the late import then +// resolves maps.helpers against that mock and throws. +import { setGeoThrottleInterval } from '../src/nest/geo/nominatim.client'; // Fixed encryption key (64 hex chars = 32 bytes) for at-rest crypto in tests process.env.ENCRYPTION_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2'; @@ -28,10 +42,4 @@ afterEach(async () => { // under test. Nineteen maps cases stub fetch and drive those paths back to back; // under the real 1.1s interval they would spend nineteen seconds asleep. The // cases that are actually about the throttle set it back for themselves. -// -// Imported dynamically, and down here rather than at the top: the client pulls -// in maps.helpers, whose UA constant calls getAppUrl() at module load. A static -// import is hoisted above the assignments above it, so the URL would be read -// from an environment this file has not finished setting up yet. -const { setGeoThrottleInterval } = await import('../src/nest/geo/nominatim.client'); setGeoThrottleInterval(0); diff --git a/server/tests/unit/db/migration-hygiene.test.ts b/server/tests/unit/db/migration-hygiene.test.ts index 4c99d28b4..358318260 100644 --- a/server/tests/unit/db/migration-hygiene.test.ts +++ b/server/tests/unit/db/migration-hygiene.test.ts @@ -17,12 +17,10 @@ */ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; +import { resolve } from 'node:path'; import { createTestDb } from '../../helpers/test-db'; -const here = dirname(fileURLToPath(import.meta.url)); -const MIGRATIONS_PATH = resolve(here, '../../../src/db/migrations.ts'); +const MIGRATIONS_PATH = resolve(__dirname, '../../../src/db/migrations.ts'); const migrationsSource = readFileSync(MIGRATIONS_PATH, 'utf8'); /** diff --git a/server/tests/unit/mcp/tools-transit.test.ts b/server/tests/unit/mcp/tools-transit.test.ts index 900fa052a..42382f5d2 100644 --- a/server/tests/unit/mcp/tools-transit.test.ts +++ b/server/tests/unit/mcp/tools-transit.test.ts @@ -41,6 +41,7 @@ vi.mock('../../../src/config', () => ({ })); import { ReservationsService } from '../../../src/nest/reservations/reservations.service'; +import type { TransitPlace } from '../../../src/nest/transit/transit.helpers'; import { TransitService } from '../../../src/nest/transit/transit.service'; // The transit tools live on the DI-discovered transit.mcp.ts since the transit @@ -157,7 +158,10 @@ describe('MCP transit tools', () => { it('forwards stop and route searches and replaces provider endpoint names', async () => { const { user } = createUser(testDb); - geocodeMock.mockResolvedValue({ results: [from] }); + // `from` is a tool-input place (name/lat/lng). A geocode result is a TransitPlace, + // which also carries `type` and `area`; the stop search only reads the name back out, + // so the fixture stays as it is instead of growing fields nothing here looks at. + geocodeMock.mockResolvedValue({ results: [from as TransitPlace] }); planMock.mockResolvedValue({ itineraries: [ itinerary, diff --git a/server/tests/unit/nest/admin-packing-templates.controller.test.ts b/server/tests/unit/nest/admin-packing-templates.controller.test.ts index ba78cb6c5..5910e654e 100644 --- a/server/tests/unit/nest/admin-packing-templates.controller.test.ts +++ b/server/tests/unit/nest/admin-packing-templates.controller.test.ts @@ -35,6 +35,16 @@ function controller(over: Partial = {}) { return { c: new AdminPackingTemplatesController(packing, { writeAudit } as unknown as AuditService), packing }; } +/** + * An override whose return value the real method signature cannot produce. Every error + * branch in PackingService sets a status, so an error envelope without one is not part + * of the service's return type, yet the controller still has a fallback for it and + * PACKTPL-003 is the case that covers that fallback. + */ +function offContract(name: K, impl: () => unknown) { + return { [name]: vi.fn(impl) } as unknown as Partial; +} + const thrown = (run: () => unknown) => { try { run(); @@ -54,12 +64,12 @@ describe('AdminPackingTemplatesController', () => { }); it('PACKTPL-002 a service {error,status} becomes that HTTP status, not a 200 body', () => { - const { c } = controller({ getPackingTemplate: vi.fn(() => ({ error: 'not found', status: 404 })) } as Partial); + const { c } = controller({ getPackingTemplate: vi.fn(() => ({ error: 'not found', status: 404 })) }); expect(thrown(() => c.get('9'))).toEqual({ status: 404, body: { error: 'not found' } }); }); it('PACKTPL-003 an error without a status defaults to 400', () => { - const { c } = controller({ createTemplateCategory: vi.fn(() => ({ error: 'name required' })) } as Partial); + const { c } = controller(offContract('createTemplateCategory', () => ({ error: 'name required' }))); expect(thrown(() => c.createCategory('1', { name: '' }))).toEqual({ status: 400, body: { error: 'name required' } }); }); diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index 847b13318..daffb4c35 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -180,8 +180,8 @@ describe('AdminController read-only getters', () => { describe('AdminController tokens + sessions', () => { it('mcp token + oauth session deletes return success and map errors', () => { - expect(adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken('2')).toEqual({ success: true }); - expect(thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) }).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } }); + expect(adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, '2', req)).toEqual({ success: true }); + expect(thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) }).deleteMcpToken(user, '9', req))).toEqual({ status: 404, body: { error: 'no token' } }); expect(thrown(() => adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) }).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } }); }); }); diff --git a/server/tests/unit/nest/admin.version-notification.test.ts b/server/tests/unit/nest/admin.version-notification.test.ts index 84e202137..80c158736 100644 --- a/server/tests/unit/nest/admin.version-notification.test.ts +++ b/server/tests/unit/nest/admin.version-notification.test.ts @@ -45,9 +45,9 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { AddonsService } from '../../../src/nest/addons/addons.service'; import { SettingsService } from '../../../src/nest/settings/settings.service'; -import { AtlasService } from '../../../src/nest/atlas/atlas.service'; import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service'; import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service'; +import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service'; import { AuthService } from '../../../src/nest/auth/auth.service'; import { PasskeyService } from '../../../src/nest/auth/passkey.service'; @@ -63,7 +63,11 @@ const realtime = new RealtimeService(); const permissions = new PermissionsService(dbs); const webauthn = new WebauthnConfigService(dbs); const userCleanup = new UserCleanupService(dbs); -const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup, new EphemeralTokenService()); +// Positional and previously wrong: an AtlasService sat in the membership slot +// and the mailer was missing entirely, so `auth` was built with its last four +// collaborators shifted by one. Nothing failed, because the version-check path +// below never reaches them. +const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService()); const svc = new AdminService( dbs, new AddonsService(dbs), @@ -72,6 +76,7 @@ const svc = new AdminService( permissions, makeNotificationsService(dbs, realtime), userCleanup, + realtime, ); const checkAndNotifyVersion = () => svc.checkAndNotifyVersion(); diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 9211315c0..16f3fde43 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -66,7 +66,7 @@ vi.mock('../../../src/scheduler', () => ({ // Imports (after mocks) // --------------------------------------------------------------------------- -import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from 'vitest'; import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; diff --git a/server/tests/unit/nest/booking-import/reservation-import.controller.test.ts b/server/tests/unit/nest/booking-import/reservation-import.controller.test.ts index 4c3dc70e0..b1891715a 100644 --- a/server/tests/unit/nest/booking-import/reservation-import.controller.test.ts +++ b/server/tests/unit/nest/booking-import/reservation-import.controller.test.ts @@ -30,33 +30,33 @@ beforeEach(() => vi.clearAllMocks()); describe('ReservationImportController.preview', () => { it('rejects an invalid mode with 400', async () => { const { c } = make(); - expect(await status(() => c.preview(user, 't1', [file()], 'bogus'))).toBe(400); + expect(await status(() => c.preview(user, 't1', [file()], { mode: 'bogus' }))).toBe(400); }); it('returns 409 for force-ai when AI is not configured', async () => { const { c } = make({ aiAvailable: vi.fn(() => false) as any }); - expect(await status(() => c.preview(user, 't1', [file()], 'force-ai'))).toBe(409); + expect(await status(() => c.preview(user, 't1', [file()], { mode: 'force-ai' }))).toBe(409); }); it('returns 503 for no-ai when the extractor is unavailable', async () => { const { c } = make({ isAvailable: vi.fn(() => false) as any }); - expect(await status(() => c.preview(user, 't1', [file()], 'no-ai'))).toBe(503); + expect(await status(() => c.preview(user, 't1', [file()], { mode: 'no-ai' }))).toBe(503); }); it('returns 400 when no files are uploaded', async () => { const { c } = make(); - expect(await status(() => c.preview(user, 't1', [], 'no-ai'))).toBe(400); + expect(await status(() => c.preview(user, 't1', [], { mode: 'no-ai' }))).toBe(400); }); it('passes the parsed mode and user id through to the service', async () => { const { c, svc } = make(); - await c.preview(user, 't1', [file()], 'fallback-on-empty'); + await c.preview(user, 't1', [file()], { mode: 'fallback-on-empty' }); expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'fallback-on-empty', 1); }); it('defaults the mode to no-ai when omitted', async () => { const { c, svc } = make(); - await c.preview(user, 't1', [file()], undefined); + await c.preview(user, 't1', [file()], {}); expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'no-ai', 1); }); }); diff --git a/server/tests/unit/nest/categories.controller.test.ts b/server/tests/unit/nest/categories.controller.test.ts index ec0fe9b62..60012ce85 100644 --- a/server/tests/unit/nest/categories.controller.test.ts +++ b/server/tests/unit/nest/categories.controller.test.ts @@ -46,7 +46,7 @@ describe('CategoriesController (parity with the legacy /api/categories route)', it('404 when the category does not exist', () => { const getById = vi.fn().mockReturnValue(undefined); const update = vi.fn(); - expect(thrown(() => makeController({ getById, update }).update('9', 'X'))).toEqual({ + expect(thrown(() => makeController({ getById, update }).update('9', { name: 'X' }))).toEqual({ status: 404, body: { error: 'Category not found' }, }); expect(update).not.toHaveBeenCalled(); diff --git a/server/tests/unit/nest/files.service.test.ts b/server/tests/unit/nest/files.service.test.ts index 59cc01c58..184d3dd7c 100644 --- a/server/tests/unit/nest/files.service.test.ts +++ b/server/tests/unit/nest/files.service.test.ts @@ -62,7 +62,7 @@ import { createUser, createTrip, addTripMember, createPlace, createReservation, import { DatabaseService, type TripAccess } from '../../../src/nest/database/database.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { FilesService } from '../../../src/nest/files/files.service'; -import { getAllowedExtensions as bridgeGetAllowedExtensions } from '../../../src/nest/files/files.bridge'; +import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service'; import { DEFAULT_ALLOWED_EXTENSIONS, MAX_FILE_SIZE, @@ -558,8 +558,13 @@ describe('verifyTripAccess / can / files.bridge', () => { expect(checkPermission).toHaveBeenLastCalledWith('file_upload', 'user', user.id, user.id, false); }); - it('FILE-SVC-038: files.bridge getAllowedExtensions delegates to the service over the shared db', () => { + it('FILE-SVC-038: the allowed-extension list is a live read, not a boot snapshot', () => { + // Was asserted against files.bridge, which built its own FilesService. The + // list moved to a leaf service the multer factories inject, and the property + // that matters is unchanged: an admin editing the list in settings applies + // to the next upload, with no invalidation wiring. + const allowed = new AllowedFileTypesService(new DatabaseService(testDb)); setAppSetting(testDb, 'allowed_file_types', 'md,markdown'); - expect(bridgeGetAllowedExtensions()).toBe('md,markdown'); + expect(allowed.get()).toBe('md,markdown'); }); }); diff --git a/server/tests/unit/nest/memories.controller.test.ts b/server/tests/unit/nest/memories.controller.test.ts index 4add893d4..a21933b12 100644 --- a/server/tests/unit/nest/memories.controller.test.ts +++ b/server/tests/unit/nest/memories.controller.test.ts @@ -5,6 +5,7 @@ import { UnifiedMemoriesController } from '../../../src/nest/memories/unified.co import { ImmichMemoriesController } from '../../../src/nest/memories/immich.controller'; import { SynologyMemoriesController } from '../../../src/nest/memories/synology.controller'; import type { MemoriesService } from '../../../src/nest/memories/memories.service'; +import type { AddTripPhotosDto, SynologySettingsDto } from '../../../src/nest/memories/memories.dto'; import type { User } from '../../../src/types'; const { getClientIp } = vi.hoisted(() => ({ getClientIp: vi.fn(() => '1.2.3.4') })); @@ -16,6 +17,16 @@ function makeService(overrides: Partial = {}): MemoriesService return { ...overrides } as unknown as MemoriesService; } +/** + * A DTO type describes a body that already passed the Zod pipe. The two cases + * that use this hand a controller a value the pipe would reject, because what + * they pin is the hand-rolled coercion running after validation — the leniency + * the Express routers had, which the controllers still carry. + */ +function offContractBody(body: Record): T { + return body as unknown as T; +} + type MockRes = Response & { status: ReturnType; json: ReturnType; @@ -78,7 +89,13 @@ describe('UnifiedMemoriesController (parity with /api/integrations/memories/unif it('ignores a non-array selections payload', async () => { const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 0 } }); const svc = makeService({ addTripPhotos }); - await new UnifiedMemoriesController(svc).addPhotos(user, '5', { selections: 'nope', shared: true }, 'sock', makeRes()); + await new UnifiedMemoriesController(svc).addPhotos( + user, + '5', + offContractBody({ selections: 'nope', shared: true }), + 'sock', + makeRes(), + ); expect(addTripPhotos).toHaveBeenCalledWith('5', 7, true, [], 'sock'); }); @@ -567,7 +584,11 @@ describe('SynologyMemoriesController (parity with /api/integrations/memories/syn expect(synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', true); const svc2 = makeService({ synologyUpdateSettings: vi.fn().mockResolvedValue({ success: true, data: {} }) }); - await new SynologyMemoriesController(svc2).putSettings(user, { synology_url: 'u', synology_username: 'a', synology_skip_ssl: 'no' }, makeRes()); + await new SynologyMemoriesController(svc2).putSettings( + user, + offContractBody({ synology_url: 'u', synology_username: 'a', synology_skip_ssl: 'no' }), + makeRes(), + ); expect(svc2.synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', false); }); }); diff --git a/server/tests/unit/nest/oauth.controller.test.ts b/server/tests/unit/nest/oauth.controller.test.ts index f01ae1520..2d0c407de 100644 --- a/server/tests/unit/nest/oauth.controller.test.ts +++ b/server/tests/unit/nest/oauth.controller.test.ts @@ -12,6 +12,7 @@ import { OauthPublicController } from '../../../src/nest/oauth/oauth-public.cont import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller'; import { RateLimitService } from '../../../src/nest/common/rate-limit.service'; import type { OauthService } from '../../../src/nest/oauth/oauth.service'; +import type { AuditService } from '../../../src/nest/audit/audit.service'; import type { User } from '../../../src/types'; function osvc(o: Partial = {}): OauthService { @@ -83,7 +84,9 @@ describe('OauthPublicController /token', () => { it('authorization_code: maps client_id / redirect_uri / resource mismatches + pkce + client auth', () => { const base = { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }; - const mk = (pending: Record, extra: Partial = {}, body = base) => { + // body is the loose form the controller actually reads off req.body, not the + // shape of `base`, so a case can add a field such as `resource`. + const mk = (pending: Record, extra: Partial = {}, body: Record = base) => { const res = makeRes(); opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue(pending), authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), verifyPKCE: vi.fn().mockReturnValue(true), ...extra }), rl()).token(reqWith(body), res); return res; diff --git a/server/tests/unit/nest/place-enrichment.brand.test.ts b/server/tests/unit/nest/place-enrichment.brand.test.ts index ff6f06027..c54ec2fc2 100644 --- a/server/tests/unit/nest/place-enrichment.brand.test.ts +++ b/server/tests/unit/nest/place-enrichment.brand.test.ts @@ -12,7 +12,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const { mockDbGet, mockDbRun } = vi.hoisted(() => ({ - mockDbGet: vi.fn(() => undefined as any), + // Declared with the shape the fake statement below calls it with (the SQL plus + // whatever the caller bound), otherwise the spread has no rest parameter to land in. + mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): unknown => undefined), mockDbRun: vi.fn(), })); diff --git a/server/tests/unit/nest/place-enrichment.description.test.ts b/server/tests/unit/nest/place-enrichment.description.test.ts index 5a9c753f3..c249c03bd 100644 --- a/server/tests/unit/nest/place-enrichment.description.test.ts +++ b/server/tests/unit/nest/place-enrichment.description.test.ts @@ -10,7 +10,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const { mockDbGet, mockDbRun } = vi.hoisted(() => ({ - mockDbGet: vi.fn(() => undefined as any), + // The prepare() stub below forwards the SQL and then the bound values, so the + // spy has to declare that shape. Inferred from a zero-arg implementation it + // would reject the spread at the call site. + mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): unknown => undefined), mockDbRun: vi.fn(), })); diff --git a/server/tests/unit/nest/tags.controller.test.ts b/server/tests/unit/nest/tags.controller.test.ts index ccdcb4615..be2f2d709 100644 --- a/server/tests/unit/nest/tags.controller.test.ts +++ b/server/tests/unit/nest/tags.controller.test.ts @@ -4,6 +4,7 @@ import { TagsController } from '../../../src/nest/tags/tags.controller'; import type { TagsService } from '../../../src/nest/tags/tags.service'; import type { User } from '../../../src/types'; import type { Tag } from '@trek/shared'; +import { anyBody } from '../../helpers/dto'; const user = { id: 5 } as User; @@ -34,7 +35,7 @@ describe('TagsController (parity with the legacy /api/tags route)', () => { describe('POST /', () => { it('400 when name is missing', () => { const create = vi.fn(); - expect(thrown(() => makeController({ create }).create(user, undefined))).toEqual({ + expect(thrown(() => makeController({ create }).create(user, anyBody()))).toEqual({ status: 400, body: { error: 'Tag name is required' }, }); expect(create).not.toHaveBeenCalled(); @@ -42,7 +43,7 @@ describe('TagsController (parity with the legacy /api/tags route)', () => { it('creates a tag for the caller', () => { const create = vi.fn().mockReturnValue(tag); - expect(makeController({ create }).create(user, 'Beach', '#10b981')).toEqual({ tag }); + expect(makeController({ create }).create(user, anyBody({ name: 'Beach', color: '#10b981' }))).toEqual({ tag }); expect(create).toHaveBeenCalledWith(5, 'Beach', '#10b981'); }); }); @@ -51,7 +52,7 @@ describe('TagsController (parity with the legacy /api/tags route)', () => { it('404 when the tag is not owned by the caller', () => { const getByIdAndUser = vi.fn().mockReturnValue(undefined); const update = vi.fn(); - expect(thrown(() => makeController({ getByIdAndUser, update }).update(user, '9', 'X'))).toEqual({ + expect(thrown(() => makeController({ getByIdAndUser, update }).update(user, '9', anyBody({ name: 'X' })))).toEqual({ status: 404, body: { error: 'Tag not found' }, }); expect(getByIdAndUser).toHaveBeenCalledWith('9', 5); @@ -61,7 +62,7 @@ describe('TagsController (parity with the legacy /api/tags route)', () => { it('updates an owned tag', () => { const getByIdAndUser = vi.fn().mockReturnValue(tag); const update = vi.fn().mockReturnValue({ ...tag, name: 'Hike' }); - expect(makeController({ getByIdAndUser, update }).update(user, '1', 'Hike')).toEqual({ tag: { ...tag, name: 'Hike' } }); + expect(makeController({ getByIdAndUser, update }).update(user, '1', anyBody({ name: 'Hike' }))).toEqual({ tag: { ...tag, name: 'Hike' } }); expect(update).toHaveBeenCalledWith('1', 'Hike', undefined); }); }); diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index 5c99e1fce..fdb3171bc 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -68,6 +68,11 @@ import { MapsService } from '../../../src/nest/maps/maps.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import { notificationsStub } from '../../helpers/notifications'; import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; +import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service'; +import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; // Real sibling services over the same in-memory DB — the aggregation runs the // actual SQL of every domain it fans out to, so a shape change downstream shows @@ -75,7 +80,15 @@ import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.se const dbs = () => new DatabaseService(testDb); const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs())); -const placesSvc = new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs()), new QueryHelpersService(dbs())); +// One shared cache instance, as in trips.bridge.ts: the in-flight dedup in +// PlacePhotoCacheService only works while both consumers hold the same object. +const photoCache = new PlacePhotoCacheService(dbs(), new RuntimeEnvService()); +const placesSvc = new PlacesService( + dbs(), new PermissionsService(dbs()), new RealtimeService(), + new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()), + new UnsplashService(dbs(), new RuntimeEnvService()), photoCache, + new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())), +); const accommodationsSvc = new AccommodationsService(dbs(), new PermissionsService(dbs()), new RealtimeService()); const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 6a179ce4f..aea947f94 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -74,6 +74,11 @@ import { TripMembersService } from '../../../src/nest/trip-members/trip-members. import { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service'; import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service'; import { MapsService } from '../../../src/nest/maps/maps.service'; +import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service'; +import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import { getTripOwner, listMembers as bridgeListMembers } from '../../../src/nest/trips/trips.bridge'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import fs from 'fs'; @@ -85,7 +90,24 @@ import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.se const dbs = () => new DatabaseService(testDb); const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs())); -const placesSvc = new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs()), new QueryHelpersService(dbs())); +// Same collaborator set the container hands PlacesService (see trips.bridge.ts). +// Only the read-model aggregation reaches into places here, but the photo cache, +// Unsplash and journey domain are real instances over the same in-memory DB +// rather than casts: the place hooks are fire-and-forget behind a catch, so a +// missing collaborator would look like a pass while swallowing a TypeError. +// One PlacePhotoCacheService for both PlacesService and MapsService, matching +// production, where the in-flight dedup only works on a shared instance. +const photoCache = new PlacePhotoCacheService(dbs(), new RuntimeEnvService()); +const placesSvc = new PlacesService( + dbs(), + new PermissionsService(dbs()), + new RealtimeService(), + new MapsService(dbs(), photoCache), + new QueryHelpersService(dbs()), + new UnsplashService(dbs(), new RuntimeEnvService()), + photoCache, + new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())), +); const accommodationsSvc = new AccommodationsService(dbs(), new PermissionsService(dbs()), new RealtimeService()); const createAccommodation = accommodationsSvc.createAccommodation.bind(accommodationsSvc); diff --git a/server/tests/unit/packing/packing.rpc.test.ts b/server/tests/unit/packing/packing.rpc.test.ts index 0f270a828..73891664c 100644 --- a/server/tests/unit/packing/packing.rpc.test.ts +++ b/server/tests/unit/packing/packing.rpc.test.ts @@ -35,10 +35,13 @@ function build(opts: { canEdit?: boolean; before?: Item | undefined; updated?: I const packing = new PackingService({} as never, {} as never, realtime, notificationsStub()); const data = { listItems: vi.fn(() => [{ id: 70, name: 'Socks' }]), - createItem: vi.fn((_t: string, i: Record) => ({ id: 70, ...i })), + // The bodies here are the boring fixture; the item writes are typed as Item so a + // case can hand back the privacy fields the real service selects (#858), which the + // fixture itself never bothers to set. + createItem: vi.fn((_t: string, i: Record): Item => ({ id: 70, ...i })), getItemPrivacy: vi.fn(() => opts.before), updateItem: vi.fn(() => (opts.updated === undefined ? { id: 70, is_private: 0 } : opts.updated)), - deleteItem: vi.fn((_t: string, id: string) => (id === '70' ? { id: 70, is_private: 0 } : null)), + deleteItem: vi.fn((_t: string, id: string): Item | null => (id === '70' ? { id: 70, is_private: 0 } : null)), listBags: vi.fn(() => [{ id: 80, name: 'Backpack' }]), createBag: vi.fn((_t: string, b: Record) => ({ id: 80, ...b })), updateBag: vi.fn((_t: string, id: string) => (id === '80' ? { id: 80 } : null)), diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index 3f1bc1ca4..6c6528e46 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -215,19 +215,19 @@ describe('PluginsController M2 endpoints', () => { it('activate spawns via the runtime when enabled', async () => { const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never; - const out = await new PluginsController(svc, rt, {} as never).activate('x'); + const out = await new PluginsController(svc, rt, {} as never).activate('x', {}); expect(out).toEqual({ status: 'active' }); }); it('activate is 503 when the runtime is disabled', async () => { process.env.TREK_PLUGINS_ENABLED = 'false'; const rt = { activate: vi.fn(), isActive: vi.fn() } as never; - await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 503 }); + await expect(new PluginsController(svc, rt, {} as never).activate('x', {})).rejects.toMatchObject({ status: 503 }); }); it('activate surfaces an activation error as 400', async () => { const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never; - await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 400 }); + await expect(new PluginsController(svc, rt, {} as never).activate('x', {})).rejects.toMatchObject({ status: 400 }); }); it('deactivate stops the plugin (and cascades to dependents)', async () => { diff --git a/server/tests/unit/reservations/bookings.rpc.test.ts b/server/tests/unit/reservations/bookings.rpc.test.ts index 81a432680..d69e07aef 100644 --- a/server/tests/unit/reservations/bookings.rpc.test.ts +++ b/server/tests/unit/reservations/bookings.rpc.test.ts @@ -18,7 +18,7 @@ import { ReservationsModule } from '../../../src/nest/reservations/reservations. import { AccommodationsRpc } from '../../../src/nest/accommodations/accommodations.rpc'; import { AccommodationsModule } from '../../../src/nest/accommodations/accommodations.module'; import type { ReservationsService } from '../../../src/nest/reservations/reservations.service'; -import type { DaysService } from '../../../src/nest/days/days.service'; +import type { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service'; import type { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; @@ -45,13 +45,15 @@ function build(opts: { canEdit?: boolean; cascade?: boolean; seenActions?: strin syncBudgetOnUpdate: vi.fn(), notifyBookingChange: vi.fn(), } as unknown as ReservationsService & Record>; + // The lodging blocks live in AccommodationsService; AccommodationsRpc still calls its + // injected copy `days`, which is why the fixture keeps that name. const days = { validateAccommodationRefs: vi.fn((_t: number, placeId?: number) => (placeId === 404 ? [{ message: 'place 404 is not on this trip' }] : [])), createAccommodation: vi.fn(() => ({ id: 60 })), getAccommodation: vi.fn((id: number) => (id === 11 ? { id: 11 } : undefined)), updateAccommodation: vi.fn(() => ({ id: 11 })), deleteAccommodation: vi.fn(() => ({ linkedReservationId: opts.cascade ? 40 : null, deletedBudgetItemId: opts.cascade ? 7 : null })), - } as unknown as DaysService & Record>; + } as unknown as AccommodationsService & Record>; const guards = new PluginGuards( { canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined)), diff --git a/server/tests/unit/services/airtrailSync.test.ts b/server/tests/unit/services/airtrailSync.test.ts index ec4dbf52a..25f253d10 100644 --- a/server/tests/unit/services/airtrailSync.test.ts +++ b/server/tests/unit/services/airtrailSync.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from 'vitest'; import { buildSavePayload } from '../../../src/nest/integrations/airtrail-sync.service'; -import type { AirtrailAirport, AirtrailFlightRaw } from '../../../src/nest/integrations/airtrail.client'; +import type { AirtrailAirport, AirtrailFlightRaw, AirtrailSavePayload } from '../../../src/nest/integrations/airtrail.client'; + +/** + * buildSavePayload spreads the raw flight, so the body also carries the + * AirTrail-owned keys the save type deliberately leaves unmodelled (terminal, + * gate, actual times, customFields, track). Read those through this view. + */ +type SavePayloadWithPassthrough = AirtrailSavePayload & Record; function airport(over: Partial = {}): AirtrailAirport { return { @@ -125,7 +132,7 @@ describe('airtrailSync.buildSavePayload', () => { metadata: JSON.stringify({ airline: 'BAW', flight_number: 'BA999', seat: '3C' }), }), existingFlight(), - ); + ) as SavePayloadWithPassthrough | null; expect(payload).toMatchObject({ id: 42, from: 'JFK', diff --git a/server/tests/unit/tags/tags.rpc.test.ts b/server/tests/unit/tags/tags.rpc.test.ts index d5d393831..389690d7b 100644 --- a/server/tests/unit/tags/tags.rpc.test.ts +++ b/server/tests/unit/tags/tags.rpc.test.ts @@ -24,6 +24,15 @@ const ctx = (actingUserId: number | undefined): PluginRpcContext => ({ pluginId: 'p', actingUserId, data: {} as PluginRpcContext['data'], + // No tags handler reaches for a peer plugin, but the peers are a real collaborator on + // the context, so they get functions that throw rather than an object whose methods + // would silently be undefined if a handler ever did reach for one. + plugins: { + call: () => Promise.reject(new Error('ctx: no peer plugin router in these cases')), + emit: () => { + throw new Error('ctx: no peer plugin router in these cases'); + }, + }, }); /** Tag 1 belongs to user 42, nothing else does. */ diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 8f1101eb7..93633d932 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -237,6 +237,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Update → v{version}', 'admin.plugins.enabledToggle': 'تفعيل الإضافة', 'plugins.notFound': 'الإضافة غير موجودة', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'الإضافات الخارجية', 'admin.plugins.subtitle': 'ثبّت وأدر إضافات الجهات الخارجية على خادمك.', 'admin.plugins.disabledTitle': 'الإضافات الخارجية معطّلة', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index fb6aaf8ec..942c05998 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -217,6 +217,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Atualizar → v{version}', 'admin.plugins.enabledToggle': 'Ativar plugin', 'plugins.notFound': 'Plugin não encontrado', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Plugins', 'admin.plugins.subtitle': 'Instale e gerencie plugins de terceiros na sua instância.', 'admin.plugins.disabledTitle': 'Os plugins estão desativados', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index a1b747746..e2bd47c33 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -430,6 +430,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Actualitzar → v{version}', 'admin.plugins.enabledToggle': 'Activar el connector', 'plugins.notFound': "No s'ha trobat el connector", + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Connectors', 'admin.plugins.subtitle': 'Instal·la i gestiona connectors de tercers a la teva instància.', 'admin.plugins.disabledTitle': 'Els connectors estan desactivats', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 0750fbb71..bef076aa9 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -218,6 +218,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Update → v{version}', 'admin.plugins.enabledToggle': 'Povolit plugin', 'plugins.notFound': 'Plugin nenalezen', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Pluginy', 'admin.plugins.subtitle': 'Instalujte a spravujte pluginy třetích stran na své instanci.', 'admin.plugins.disabledTitle': 'Pluginy jsou zakázány', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 474ae4435..a4a887ae3 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -221,6 +221,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Update → v{version}', 'admin.plugins.enabledToggle': 'Plugin aktivieren', 'plugins.notFound': 'Plugin nicht gefunden', + 'plugins.frameLoadFailed': 'Dieses Plugin konnte nicht geladen werden.', 'admin.plugins.title': 'Plugins', 'admin.plugins.subtitle': 'Plugins von Drittanbietern auf deiner Instanz installieren und verwalten.', 'admin.plugins.disabledTitle': 'Plugins sind deaktiviert', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 65630506c..8f561f8c2 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -271,6 +271,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Update → v{version}', 'admin.plugins.enabledToggle': 'Enable plugin', 'plugins.notFound': 'Plugin not found', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Plugins', 'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.', 'admin.plugins.disabledTitle': 'Plugins are disabled', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 36763441a..1d5f4be51 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -219,6 +219,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Actualizar → v{version}', 'admin.plugins.enabledToggle': 'Activar plugin', 'plugins.notFound': 'Plugin no encontrado', + 'plugins.frameLoadFailed': 'No se ha podido cargar este plugin.', 'admin.plugins.title': 'Plugins', 'admin.plugins.subtitle': 'Instala y gestiona plugins de terceros en tu instancia.', 'admin.plugins.disabledTitle': 'Los plugins están desactivados', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index fab1c2e5e..4439559f6 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -222,6 +222,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Update → v{version}', 'admin.plugins.enabledToggle': 'Activer le plugin', 'plugins.notFound': 'Plugin introuvable', + 'plugins.frameLoadFailed': "Ce plugin n'a pas pu être chargé.", 'admin.plugins.title': 'Plugins', 'admin.plugins.subtitle': 'Installez et gérez des plugins tiers sur votre instance.', 'admin.plugins.disabledTitle': 'Les plugins sont désactivés', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index b71f400ae..33eed1810 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -266,6 +266,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Update → v{version}', 'admin.plugins.enabledToggle': 'Ενεργοποίηση plugin', 'plugins.notFound': 'Το plugin δεν βρέθηκε', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Plugins', 'admin.plugins.subtitle': 'Εγκαταστήστε και διαχειριστείτε plugins τρίτων στην εγκατάστασή σας.', 'admin.plugins.disabledTitle': 'Τα plugins είναι απενεργοποιημένα', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 2dcc0203c..e9c9f90d9 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -220,6 +220,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Frissítés → v{version}', 'admin.plugins.enabledToggle': 'Plugin engedélyezése', 'plugins.notFound': 'A plugin nem található', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Pluginok', 'admin.plugins.subtitle': 'Harmadik féltől származó pluginok telepítése és kezelése a példányodon.', 'admin.plugins.disabledTitle': 'A pluginok le vannak tiltva', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 20e263142..96af5b525 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -262,6 +262,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Update → v{version}', 'admin.plugins.enabledToggle': 'Aktifkan plugin', 'plugins.notFound': 'Plugin tidak ditemukan', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Plugins', 'admin.plugins.subtitle': 'Pasang dan kelola plugin pihak ketiga di instans kamu.', 'admin.plugins.disabledTitle': 'Plugin dinonaktifkan', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index da2935508..8d7c468a8 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -219,6 +219,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Aggiorna → v{version}', 'admin.plugins.enabledToggle': 'Abilita plugin', 'plugins.notFound': 'Plugin non trovato', + 'plugins.frameLoadFailed': 'Impossibile caricare questo plugin.', 'admin.plugins.title': 'Plugin', 'admin.plugins.subtitle': 'Installa e gestisci plugin di terze parti sulla tua istanza.', 'admin.plugins.disabledTitle': 'I plugin sono disabilitati', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 7ed8e74bf..b53b05713 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -251,6 +251,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': '更新 → v{version}', 'admin.plugins.enabledToggle': 'プラグインを有効にする', 'plugins.notFound': 'プラグインが見つかりません', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'プラグイン', 'admin.plugins.subtitle': 'サードパーティ製プラグインをインスタンスにインストール・管理します。', 'admin.plugins.disabledTitle': 'プラグインは無効になっています', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 18f91d3e0..ea5f4df7a 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -256,6 +256,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': '업데이트 → v{version}', 'admin.plugins.enabledToggle': '플러그인 활성화', 'plugins.notFound': '플러그인을 찾을 수 없습니다', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': '플러그인', 'admin.plugins.subtitle': '인스턴스에 서드파티 플러그인을 설치하고 관리하세요.', 'admin.plugins.disabledTitle': '플러그인이 비활성화되어 있습니다', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 3382ca686..7f779b8b8 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -220,6 +220,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Update → v{version}', 'admin.plugins.enabledToggle': 'Plugin inschakelen', 'plugins.notFound': 'Plugin niet gevonden', + 'plugins.frameLoadFailed': 'Deze plug-in kon niet worden geladen.', 'admin.plugins.title': 'Plugins', 'admin.plugins.subtitle': 'Installeer en beheer plugins van derden op je instantie.', 'admin.plugins.disabledTitle': 'Plugins zijn uitgeschakeld', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index dfab9d417..92fd96fa7 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -213,6 +213,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Update → v{version}', 'admin.plugins.enabledToggle': 'Włącz wtyczkę', 'plugins.notFound': 'Nie znaleziono wtyczki', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Wtyczki', 'admin.plugins.subtitle': 'Instaluj wtyczki innych autorów i zarządzaj nimi na swojej instancji.', 'admin.plugins.disabledTitle': 'Wtyczki są wyłączone', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 682ecf79b..b0fbbab5a 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -220,6 +220,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Обновить → v{version}', 'admin.plugins.enabledToggle': 'Включить плагин', 'plugins.notFound': 'Плагин не найден', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Плагины', 'admin.plugins.subtitle': 'Устанавливайте сторонние плагины и управляйте ими на вашем экземпляре.', 'admin.plugins.disabledTitle': 'Плагины отключены', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index f70e3709f..bb504ef5f 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -276,6 +276,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Uppdatera → v{version}', 'admin.plugins.enabledToggle': 'Aktivera plugin', 'plugins.notFound': 'Pluginet hittades inte', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Plugins', 'admin.plugins.subtitle': 'Installera och hantera tredjepartsplugins på din instans.', 'admin.plugins.disabledTitle': 'Plugins är inaktiverade', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index ac36b38cc..1b470360f 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -264,6 +264,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Güncelle → v{version}', 'admin.plugins.enabledToggle': 'Eklentiyi etkinleştir', 'plugins.notFound': 'Eklenti bulunamadı', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Plugins', 'admin.plugins.subtitle': 'Örneğinizde üçüncü taraf eklentileri yükleyin ve yönetin.', 'admin.plugins.disabledTitle': 'Eklentiler devre dışı', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index e90fa696c..a534d0810 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -219,6 +219,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Оновити → v{version}', 'admin.plugins.enabledToggle': 'Увімкнути плагін', 'plugins.notFound': 'Плагін не знайдено', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Плагіни', 'admin.plugins.subtitle': 'Встановлюйте сторонні плагіни та керуйте ними на вашому інстансі.', 'admin.plugins.disabledTitle': 'Плагіни вимкнено', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 6d5af8658..b52f593b3 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -275,6 +275,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': 'Cập nhật → v{version}', 'admin.plugins.enabledToggle': 'Bật plugin', 'plugins.notFound': 'Không tìm thấy plugin', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': 'Plugins', 'admin.plugins.subtitle': 'Cài đặt và quản lý các plugin bên thứ ba trên hệ thống của bạn.', 'admin.plugins.disabledTitle': 'Plugin đang bị tắt', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index f3c99e973..3e12a6e15 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -251,6 +251,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': '更新 → v{version}', 'admin.plugins.enabledToggle': '啟用外掛', 'plugins.notFound': '找不到外掛', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': '外掛', 'admin.plugins.subtitle': '在你的執行個體上安裝並管理第三方外掛。', 'admin.plugins.disabledTitle': '外掛已停用', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 98e618a57..c74308a66 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -211,6 +211,7 @@ const admin: TranslationStrings = { 'admin.plugins.updateTo': '更新 → v{version}', 'admin.plugins.enabledToggle': '启用插件', 'plugins.notFound': '未找到插件', + 'plugins.frameLoadFailed': 'This plugin could not be loaded.', 'admin.plugins.title': '插件', 'admin.plugins.subtitle': '在你的实例上安装和管理第三方插件。', 'admin.plugins.disabledTitle': '插件功能已禁用',