diff --git a/server/src/nest/collab/collab.controller.ts b/server/src/nest/collab/collab.controller.ts index 8768ab306..b7a39e477 100644 --- a/server/src/nest/collab/collab.controller.ts +++ b/server/src/nest/collab/collab.controller.ts @@ -1,6 +1,8 @@ import type { User } from '../../types'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { DEMO_WRITE_ERROR, isDemoWriteBlocked } from '../common/demo-write'; import { ResponseContract } from '../common/response-contract'; import { SpoolCleanupInterceptor } from '../common/spool-cleanup.interceptor'; import { BLOCKED_EXTENSIONS, MAX_FILE_SIZE } from '../files/files.constants'; @@ -130,6 +132,8 @@ export class CollabController { constructor( private readonly collab: CollabService, private readonly storage: StorageService, + // Whether DEMO_MODE is on, read on every upload: the demo account's are refused. + private readonly env: RuntimeEnvService, ) {} private async requireTrip(tripId: string, user: User) { @@ -242,6 +246,12 @@ export class CollabController { }; try { const trip = await this.requireTrip(tripId, user); + // After the 404, as on the trip-file upload, so the demo account cannot + // learn which trips exist; refused like every other upload of it + // (common/demo-write.ts). + if (isDemoWriteBlocked(this.env, user.email)) { + throw new HttpException(DEMO_WRITE_ERROR, 403); + } if (!(await this.collab.canUploadFiles(trip, user))) { throw new HttpException({ error: 'No permission to upload files' }, 403); } @@ -491,6 +501,11 @@ export class CollabController { cleanupSpool(); throw err; } + // Only the images are refused to the demo account; its text still posts. + if (uploaded.length && isDemoWriteBlocked(this.env, user.email)) { + cleanupSpool(); + throw new HttpException(DEMO_WRITE_ERROR, 403); + } if (uploaded.length && !(await this.collab.canUploadFiles(trip, user))) { cleanupSpool(); throw new HttpException({ error: 'No permission to upload files' }, 403); diff --git a/server/src/nest/collab/collab.module.ts b/server/src/nest/collab/collab.module.ts index 7babd2385..7c891e449 100644 --- a/server/src/nest/collab/collab.module.ts +++ b/server/src/nest/collab/collab.module.ts @@ -7,6 +7,7 @@ import { CollabPolls } from '../../db/entities/CollabPolls.entity'; import { Trips } from '../../db/entities/Trips.entity'; import { PluginGuardsModule } from '../../nest-rpc/plugin-guards.module'; import { AddonsModule } from '../addons/addons.module'; +import { AppConfigModule } from '../app-config/app-config.module'; import { RateLimitModule } from '../common/rate-limit.module'; import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -57,6 +58,8 @@ import { MulterModule } from '@nestjs/platform-express'; PluginGuardsModule, AddonsModule, RateLimitModule, + // RuntimeEnvService: the controller refuses the demo account's uploads in demo mode. + AppConfigModule, ], controllers: [CollabController], providers: [CollabService, CollabMcp, CollabRpc], diff --git a/server/src/nest/common/demo-write.ts b/server/src/nest/common/demo-write.ts index 7d3855dc1..6d91c9ed3 100644 --- a/server/src/nest/common/demo-write.ts +++ b/server/src/nest/common/demo-write.ts @@ -3,7 +3,7 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { DEMO_ACCOUNT_SETTINGS, isDemoEmail } from './demo'; import { DomainError } from './domain-error'; -/** The exact 403 body the six upload endpoints have always returned. */ +/** The exact 403 body the upload endpoints answer the demo account with. */ export const DEMO_WRITE_ERROR = { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.', } as const; @@ -12,9 +12,11 @@ export const DEMO_WRITE_ERROR = { * Whether this user's writes are blocked because the instance runs in demo mode. * * The condition was copy-pasted into six upload handlers, each with its own copy - * of the message. It lives here once now, and every one of them calls it. + * of the message. It lives here once now, and every one of them calls it, as do + * the journey photo, video and cover uploads and the collab note file and chat + * image uploads that came without it. * - * Deliberately a function and not a guard, though all six sites are handlers and + * Deliberately a function and not a guard, though every site is a handler and * a guard is the obvious shape. Guards run before the multipart parser, so * throwing from one leaves the request body unread and Node resets the * connection: the client sees ECONNRESET instead of the 403. PROFILE-015 catches diff --git a/server/src/nest/common/demo.service.ts b/server/src/nest/common/demo.service.ts index 546a12066..9bdd88f76 100644 --- a/server/src/nest/common/demo.service.ts +++ b/server/src/nest/common/demo.service.ts @@ -16,7 +16,7 @@ import { Injectable } from '@nestjs/common'; * hazard, confirmed by `task-0-report.md` §6), so an injected `EntityManager` * resolving the current request's fork is exactly the right shape. * - * `isDemoWriteBlocked`/`DEMO_WRITE_ERROR` (the six-upload-endpoint sibling + * `isDemoWriteBlocked`/`DEMO_WRITE_ERROR` (the upload endpoints' sibling * check) stay exactly where they are, in `common/demo-write.ts` — a * different, pure-function check over an already-known email string that * this task does not touch. diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index 892aeb6c5..cdbd15b2b 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -22,6 +22,8 @@ import path from 'node:path'; import fs from 'node:fs'; import crypto from 'node:crypto'; import type { User } from '../../types'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; +import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write'; import { StorageService } from '../storage/storage.service'; import { journeyThumbName } from '../memories/thumbnail.service'; import { JourneyService } from './journey.service'; @@ -172,6 +174,8 @@ export class JourneyController { private readonly photoCapture: JourneyPhotoCaptureService, // Which entries the gallery's photos sit on, told to every tab once written. private readonly photoLinks: JourneyPhotoLinksService, + // Whether DEMO_MODE is on, read on every upload (refuseDemoUpload). + private readonly env: RuntimeEnvService, ) {} /** @@ -208,6 +212,22 @@ export class JourneyController { } } + /** + * Refuse the shared demo account's upload in demo mode, with the 403 the + * other upload routes answer (common/demo-write.ts). Called in the handler, + * once multer has drained the body, so the 403 arrives instead of an + * ECONNRESET. It runs before the commit: the spooled parts are all there is + * to take back, and a committed object would stay, since a demo instance + * keeps what is deleted (StorageService.delete). + */ + private refuseDemoUpload(user: User, files: ReadonlyArray): void { + if (!isDemoWriteBlocked(this.env, user.email)) return; + for (const f of files) { + if (f?.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } } + } + throw new HttpException(DEMO_WRITE_ERROR, 403); + } + // ── Static prefix routes (before /:id) ────────────────────────────────── @Get() @ResponseContract(journeyListResponseSchema) @@ -271,6 +291,7 @@ export class JourneyController { @Post('entries/:entryId/photos') @UseInterceptors(FilesInterceptor('photos')) async uploadEntryPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, @Body() body: JourneyEntryPhotoUploadDto) { + this.refuseDemoUpload(user, files ?? []); if (!files?.length) { throw new HttpException({ error: 'No files uploaded' }, 400); } @@ -337,6 +358,7 @@ export class JourneyController { ) { const video = files?.video?.[0]; const poster = files?.poster?.[0]; + this.refuseDemoUpload(user, [video, poster]); const cleanup = () => { for (const f of [video, poster]) { if (f?.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } } @@ -478,6 +500,7 @@ export class JourneyController { @Post(':id/gallery/photos') @UseInterceptors(FilesInterceptor('photos')) async uploadGalleryPhotos(@CurrentUser() user: User, @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[] | undefined) { + this.refuseDemoUpload(user, files ?? []); if (!files?.length) { throw new HttpException({ error: 'No files uploaded' }, 400); } @@ -510,6 +533,7 @@ export class JourneyController { ) { const video = files?.video?.[0]; const poster = files?.poster?.[0]; + this.refuseDemoUpload(user, [video, poster]); // multer already spooled both parts; clean them up on a pre-commit rejection // so a bad POST doesn't orphan a 500 MB clip (#823). const cleanup = () => { @@ -580,6 +604,7 @@ export class JourneyController { @HttpCode(200) // Express answers cover with res.json (200). @UseInterceptors(FileInterceptor('cover')) async cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) { + this.refuseDemoUpload(user, [file]); 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 2ef49913e..4aef50d2d 100644 --- a/server/src/nest/journey/journey.module.ts +++ b/server/src/nest/journey/journey.module.ts @@ -1,5 +1,6 @@ import { Users } from '../../db/entities/Users.entity'; import { AddonsModule } from '../addons/addons.module'; +import { AppConfigModule } from '../app-config/app-config.module'; import { UserConnectionRepositoriesModule } from '../database/user-connection-repositories.module'; import { AllowedFileTypesModule } from '../files/allowed-file-types.module'; import { AllowedFileTypesService } from '../files/allowed-file-types.service'; @@ -42,6 +43,8 @@ import { MulterModule } from '@nestjs/platform-express'; }), StorageModule, AddonsModule, + // RuntimeEnvService: the controller refuses the demo account's uploads in demo mode. + AppConfigModule, MemoriesModule, JourneyDomainModule, JourneyPhotoCaptureModule, diff --git a/server/tests/e2e/collab.e2e.test.ts b/server/tests/e2e/collab.e2e.test.ts index 93d889645..4f68207a0 100644 --- a/server/tests/e2e/collab.e2e.test.ts +++ b/server/tests/e2e/collab.e2e.test.ts @@ -32,8 +32,9 @@ import { Test } from '@nestjs/testing'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; +import path from 'node:path'; import request from 'supertest'; -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi, type MockInstance } from 'vitest'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); @@ -329,4 +330,50 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => { ); }); }); + + describe('demo mode', () => { + // Over the wire, because a refusal from a guard would reset the connection + // while the client is still sending (PROFILE-015): the 403 has to arrive with + // its body, and nothing of the upload may be written. + const refused = { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }; + + beforeAll(async () => { + // Pinned id: sessionCookie(2) signs for exactly this user, the owner of trip 6. + await makeUser(orm, { id: 2, username: 'demo', email: 'demo@trek.app', role: 'user', password_version: 0 }); + await insertRow(orm, Trips, { id: 6, title: 'Demo trip', user: 2 }); + }); + beforeEach(() => { + vi.stubEnv('DEMO_MODE', 'true'); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('403 with the demo body for a note file, and no file row', async () => { + const res = await request(server) + .post('/api/trips/6/collab/notes/1/files') + .set('Cookie', sessionCookie(2)) + .attach('file', path.join(__dirname, '../fixtures/test.pdf')); + expect(res.status).toBe(403); + expect(res.body).toEqual(refused); + expect(await countRows(orm, TripFiles)).toBe(0); + }); + + it('403 with the demo body for a chat image, and no message row; a text message still posts', async () => { + const res = await request(server) + .post('/api/trips/6/collab/messages') + .set('Cookie', sessionCookie(2)) + .field('text', 'look') + .attach('images', path.join(__dirname, '../fixtures/small-image.jpg')); + expect(res.status).toBe(403); + expect(res.body).toEqual(refused); + expect(await countRows(orm, CollabMessages)).toBe(0); + const text = await request(server) + .post('/api/trips/6/collab/messages') + .set('Cookie', sessionCookie(2)) + .send({ text: 'hi' }); + expect(text.status).toBe(201); + expect(text.body.message).toMatchObject({ text: 'hi', trip_id: 6, user_id: 2 }); + }); + }); }); diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index 9d02158d4..a15a6457c 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -28,7 +28,7 @@ import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import path from 'node:path'; import request from 'supertest'; -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; const { db } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -749,4 +749,39 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => { expect(jsvc.linkPhotoToEntry).not.toHaveBeenCalled(); }); }); + + describe('demo mode', () => { + // Over the wire, because a refusal from a guard would reset the connection + // while the client is still sending (PROFILE-015): the 403 has to arrive with + // its body, and nothing of the upload may reach the service. + const image = path.join(__dirname, '../fixtures/small-image.jpg'); + const clip = { filename: 'clip.mp4', contentType: 'video/mp4' }; + + beforeAll(() => { + seedUser(db as never, { id: 2, username: 'demo', email: 'demo@trek.app' }); + }); + beforeEach(() => { + vi.stubEnv('DEMO_MODE', 'true'); + jsvc.uploadGalleryPhotos.mockClear(); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + ['/api/journeys/entries/3/photos', 'photos'], + ['/api/journeys/entries/3/video', 'video'], + ['/api/journeys/9/gallery/photos', 'photos'], + ['/api/journeys/9/gallery/video', 'video'], + ['/api/journeys/9/cover', 'cover'], + ])('JOURNEY-E2E-020: POST %s answers the demo account 403 with the demo body', async (url, field) => { + const req = request(server).post(url).set('Cookie', sessionCookie(2)); + const res = await (field === 'video' + ? req.attach(field, Buffer.from('not a clip'), clip) + : req.attach(field, image)); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }); + expect(jsvc.uploadGalleryPhotos).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/tests/unit/nest/collab.controller.test.ts b/server/tests/unit/nest/collab.controller.test.ts index 44451552c..d2297e89e 100644 --- a/server/tests/unit/nest/collab.controller.test.ts +++ b/server/tests/unit/nest/collab.controller.test.ts @@ -1,3 +1,4 @@ +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard'; import { CollabController, @@ -14,7 +15,7 @@ import { HttpException } from '@nestjs/common'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User; @@ -49,6 +50,14 @@ const storageStub = { delete: vi.fn().mockResolvedValue(undefined), } as unknown as StorageService; +/** + * Build the controller over its stubs. The env is the live one, so a case about + * demo mode stubs DEMO_MODE and every other case runs outside it. + */ +function ctl(service: CollabService, storage: StorageService = storageStub): CollabController { + return new CollabController(service, storage, new RuntimeEnvService()); +} + async function thrownAsync(fn: () => Promise): Promise<{ status: number; body: unknown }> { try { await fn(); @@ -66,15 +75,15 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou describe('notes', () => { it('GET lists', async () => { const s = svc({ listNotes: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial); - expect(await new CollabController(s, storageStub).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] }); + expect(await ctl(s, storageStub).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] }); }); it('POST creates through the posting use case, which broadcasts and notifies (empty title now 400s in the Zod pipe)', async () => { const createNote = vi.fn().mockReturnValue({ id: 9 }); const s = svc({ createNote } as Partial); - expect( - await new CollabController(s, storageStub).createNote(user, '5', { title: 'T', content: 'c' }, 'sock'), - ).toEqual({ note: { id: 9 } }); + expect(await ctl(s, storageStub).createNote(user, '5', { title: 'T', content: 'c' }, 'sock')).toEqual({ + note: { id: 9 }, + }); const fields = { title: 'T', content: 'c', category: undefined, color: undefined, website: undefined }; expect(createNote).toHaveBeenCalledWith('5', 1, fields); expect(s.restWriter).toHaveBeenCalledWith('5', user, 'sock'); @@ -86,15 +95,17 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou it('PUT 404 when missing, else updates + broadcasts', async () => { expect( await thrownAsync(() => - new CollabController( - svc({ updateNote: vi.fn().mockResolvedValue(null) } as Partial), - storageStub, - ).updateNote(user, '5', '9', {}), + ctl(svc({ updateNote: vi.fn().mockResolvedValue(null) } as Partial), storageStub).updateNote( + user, + '5', + '9', + {}, + ), ), ).toEqual({ status: 404, body: { error: 'Note not found' } }); const broadcast = vi.fn(); const s = svc({ updateNote: vi.fn().mockResolvedValue({ id: 9 }), broadcast } as Partial); - expect(await new CollabController(s, storageStub).updateNote(user, '5', '9', { title: 'b' }, 'sock')).toEqual({ + expect(await ctl(s, storageStub).updateNote(user, '5', '9', { title: 'b' }, 'sock')).toEqual({ note: { id: 9 }, }); expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:updated', { note: { id: 9 } }, 'sock'); @@ -103,15 +114,16 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou it('DELETE 404 when missing, else success + broadcasts', async () => { expect( await thrownAsync(() => - new CollabController( - svc({ deleteNote: vi.fn().mockResolvedValue(false) } as Partial), - storageStub, - ).deleteNote(user, '5', '9'), + ctl(svc({ deleteNote: vi.fn().mockResolvedValue(false) } as Partial), storageStub).deleteNote( + user, + '5', + '9', + ), ), ).toEqual({ status: 404, body: { error: 'Note not found' } }); const broadcast = vi.fn(); const s = svc({ deleteNote: vi.fn().mockResolvedValue(true), broadcast } as Partial); - expect(await new CollabController(s, storageStub).deleteNote(user, '5', '9', 'sock')).toEqual({ success: true }); + expect(await ctl(s, storageStub).deleteNote(user, '5', '9', 'sock')).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:deleted', { noteId: 9 }, 'sock'); }); }); @@ -121,24 +133,22 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou it('403 without file_upload, 400 without file, 404 unknown note, else commits + returns result', async () => { expect( await thrownAsync(() => - new CollabController(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile( + ctl(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(user, '5', '9', file), + ), + ).toEqual({ status: 403, body: { error: 'No permission to upload files' } }); + expect(await thrownAsync(() => ctl(svc(), storageStub).addNoteFile(user, '5', '9', undefined))).toEqual({ + status: 400, + body: { error: 'No file uploaded' }, + }); + expect( + await thrownAsync(() => + ctl(svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial), storageStub).addNoteFile( user, '5', '9', file, ), ), - ).toEqual({ status: 403, body: { error: 'No permission to upload files' } }); - expect( - await thrownAsync(() => new CollabController(svc(), storageStub).addNoteFile(user, '5', '9', undefined)), - ).toEqual({ status: 400, body: { error: 'No file uploaded' } }); - expect( - await thrownAsync(() => - new CollabController( - svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial), - storageStub, - ).addNoteFile(user, '5', '9', file), - ), ).toEqual({ status: 404, body: { error: 'Note not found' } }); const broadcast = vi.fn(); const s = svc({ @@ -146,7 +156,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }), broadcast, } as Partial); - expect(await new CollabController(s, storageStub).addNoteFile(user, '5', '9', file, 'sock')).toEqual({ + expect(await ctl(s, storageStub).addNoteFile(user, '5', '9', file, 'sock')).toEqual({ file: { id: 3 }, }); expect(storageStub.put).toHaveBeenCalledWith('files', 'a.pdf', { tmpPath: undefined }); @@ -161,7 +171,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou const spooled = { filename: 'a.pdf', path: spool } as Express.Multer.File; expect( await thrownAsync(() => - new CollabController(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile( + ctl(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile( user, '5', '9', @@ -175,12 +185,10 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou fs.writeFileSync(spool2, 'x'); expect( await thrownAsync(() => - new CollabController(svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) }), storageStub).addNoteFile( - user, - '5', - '9', - { filename: 'b.pdf', path: spool2 } as Express.Multer.File, - ), + ctl(svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) }), storageStub).addNoteFile(user, '5', '9', { + filename: 'b.pdf', + path: spool2, + } as Express.Multer.File), ), ).toEqual({ status: 404, body: { error: 'Trip not found' } }); expect(fs.existsSync(spool2)).toBe(false); @@ -188,10 +196,12 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou // Past the commit the spool file is gone, so the final object is what has // to go instead. await thrownAsync(() => - new CollabController( - svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial), - storageStub, - ).addNoteFile(user, '5', '9', file), + ctl(svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial), storageStub).addNoteFile( + user, + '5', + '9', + file, + ), ); expect(storageStub.delete).toHaveBeenCalledWith('files', 'a.pdf'); }); @@ -199,7 +209,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou it('DELETE file 404 when missing, else success', async () => { expect( await thrownAsync(() => - new CollabController( + ctl( svc({ deleteNoteFile: vi.fn().mockResolvedValue(false) } as Partial), storageStub, ).deleteNoteFile(user, '5', '9', '3'), @@ -210,22 +220,22 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }), broadcast: vi.fn(), } as Partial); - expect(await new CollabController(s, storageStub).deleteNoteFile(user, '5', '9', '3')).toEqual({ success: true }); + expect(await ctl(s, storageStub).deleteNoteFile(user, '5', '9', '3')).toEqual({ success: true }); }); }); describe('polls', () => { it('POST creates (missing question / <2 options now 400 in the Zod pipe)', async () => { const s = svc({ createPoll: vi.fn().mockResolvedValue({ id: 7 }), broadcast: vi.fn() } as Partial); - expect( - await new CollabController(s, storageStub).createPoll(user, '5', { question: 'q', options: ['a', 'b'] }), - ).toEqual({ poll: { id: 7 } }); + expect(await ctl(s, storageStub).createPoll(user, '5', { question: 'q', options: ['a', 'b'] })).toEqual({ + poll: { id: 7 }, + }); }); it('vote maps not_found/closed/invalid_index, else broadcasts the poll', async () => { expect( await thrownAsync(() => - new CollabController( + ctl( svc({ votePoll: vi.fn().mockResolvedValue({ error: 'not_found' }) } as Partial), storageStub, ).votePoll(user, '5', '7', { option_index: 0 }), @@ -233,7 +243,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou ).toEqual({ status: 404, body: { error: 'Poll not found' } }); expect( await thrownAsync(() => - new CollabController( + ctl( svc({ votePoll: vi.fn().mockResolvedValue({ error: 'closed' }) } as Partial), storageStub, ).votePoll(user, '5', '7', { option_index: 0 }), @@ -241,7 +251,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou ).toEqual({ status: 400, body: { error: 'Poll is closed' } }); expect( await thrownAsync(() => - new CollabController( + ctl( svc({ votePoll: vi.fn().mockResolvedValue({ error: 'invalid_index' }) } as Partial), storageStub, ).votePoll(user, '5', '7', { option_index: 9 }), @@ -249,7 +259,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou ).toEqual({ status: 400, body: { error: 'Invalid option index' } }); const broadcast = vi.fn(); const s = svc({ votePoll: vi.fn().mockResolvedValue({ poll: { id: 7 } }), broadcast } as Partial); - expect(await new CollabController(s, storageStub).votePoll(user, '5', '7', { option_index: 0 }, 'sock')).toEqual({ + expect(await ctl(s, storageStub).votePoll(user, '5', '7', { option_index: 0 }, 'sock')).toEqual({ poll: { id: 7 }, }); expect(broadcast).toHaveBeenCalledWith('5', 'collab:poll:voted', { poll: { id: 7 } }, 'sock'); @@ -258,27 +268,29 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou it('close 404 when missing, else broadcasts', async () => { expect( await thrownAsync(() => - new CollabController( - svc({ closePoll: vi.fn().mockResolvedValue(null) } as Partial), - storageStub, - ).closePoll(user, '5', '7'), + ctl(svc({ closePoll: vi.fn().mockResolvedValue(null) } as Partial), storageStub).closePoll( + user, + '5', + '7', + ), ), ).toEqual({ status: 404, body: { error: 'Poll not found' } }); const s = svc({ closePoll: vi.fn().mockResolvedValue({ id: 7 }), broadcast: vi.fn() } as Partial); - expect(await new CollabController(s, storageStub).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } }); + expect(await ctl(s, storageStub).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } }); }); it('delete 404 when missing, else success', async () => { expect( await thrownAsync(() => - new CollabController( - svc({ deletePoll: vi.fn().mockResolvedValue(false) } as Partial), - storageStub, - ).deletePoll(user, '5', '7'), + ctl(svc({ deletePoll: vi.fn().mockResolvedValue(false) } as Partial), storageStub).deletePoll( + user, + '5', + '7', + ), ), ).toEqual({ status: 404, body: { error: 'Poll not found' } }); const s = svc({ deletePoll: vi.fn().mockResolvedValue(true), broadcast: vi.fn() } as Partial); - expect(await new CollabController(s, storageStub).deletePoll(user, '5', '7')).toEqual({ success: true }); + expect(await ctl(s, storageStub).deletePoll(user, '5', '7')).toEqual({ success: true }); }); }); @@ -288,25 +300,24 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou it('POST 400 whitespace-only, 400 reply_not_found, else creates + notifies (length checks now in the Zod pipe)', async () => { // A request with no file part keeps the wording it always had. expect( - await thrownAsync(() => - new CollabController(svc(), storageStub).createMessage(user, '5', { text: ' ' }, undefined), - ), + await thrownAsync(() => ctl(svc(), storageStub).createMessage(user, '5', { text: ' ' }, undefined)), ).toEqual({ status: 400, body: { error: 'Message text is required' } }); - expect( - await thrownAsync(() => new CollabController(svc(), storageStub).createMessage(user, '5', { text: ' ' }, [])), - ).toEqual({ status: 400, body: { error: 'Message text or image is required' } }); + expect(await thrownAsync(() => ctl(svc(), storageStub).createMessage(user, '5', { text: ' ' }, []))).toEqual({ + status: 400, + body: { error: 'Message text or image is required' }, + }); expect( await thrownAsync(() => - new CollabController( + ctl( svc({ createMessage: vi.fn().mockResolvedValue({ error: 'reply_not_found' }) } as Partial), storageStub, ).createMessage(user, '5', { text: 'hi', reply_to: 99 }, undefined), ), ).toEqual({ status: 400, body: { error: 'Reply target message not found' } }); const s = svc({ createMessage: vi.fn().mockResolvedValue({ message: { id: 3 } }) } as Partial); - expect( - await new CollabController(s, storageStub).createMessage(user, '5', { text: 'hello' }, undefined, 'sock'), - ).toEqual({ message: { id: 3 } }); + expect(await ctl(s, storageStub).createMessage(user, '5', { text: 'hello' }, undefined, 'sock')).toEqual({ + message: { id: 3 }, + }); // The posting use case sends and notifies; the route hands it the trimmed text and its writer. expect(s.postMessage).toHaveBeenCalledWith('5', { text: 'hello', replyTo: null, files: [] }, restWriter('sock')); expect(s.broadcast).not.toHaveBeenCalled(); @@ -317,7 +328,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou // refusals are the only thing standing in front of the route now. expect( await thrownAsync(() => - new CollabController( + ctl( svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) } as Partial), storageStub, ).createMessage(user, '5', { text: 'hi' }, undefined), @@ -325,10 +336,12 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou ).toEqual({ status: 404, body: { error: 'Trip not found' } }); expect( await thrownAsync(() => - new CollabController( - svc({ canEdit: vi.fn().mockResolvedValue(false) } as Partial), - storageStub, - ).createMessage(user, '5', { text: 'hi' }, undefined), + ctl(svc({ canEdit: vi.fn().mockResolvedValue(false) } as Partial), storageStub).createMessage( + user, + '5', + { text: 'hi' }, + undefined, + ), ), ).toEqual({ status: 403, body: { error: 'No permission' } }); }); @@ -336,7 +349,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou it('react 404 unknown, else broadcasts reactions (empty emoji now 400s in the Zod pipe)', async () => { expect( await thrownAsync(() => - new CollabController( + ctl( svc({ reactMessage: vi.fn().mockResolvedValue({ found: false, reactions: [] }) } as Partial), storageStub, ).react(user, '5', '3', { emoji: '👍' }), @@ -347,7 +360,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou reactMessage: vi.fn().mockResolvedValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] }), broadcast, } as Partial); - expect(await new CollabController(s, storageStub).react(user, '5', '3', { emoji: '👍' }, 'sock')).toEqual({ + expect(await ctl(s, storageStub).react(user, '5', '3', { emoji: '👍' }, 'sock')).toEqual({ reactions: [{ emoji: '👍', count: 1 }], }); expect(broadcast).toHaveBeenCalledWith( @@ -361,7 +374,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou it('delete maps not_found (404) / not_owner (403), else success with username', async () => { expect( await thrownAsync(() => - new CollabController( + ctl( svc({ deleteMessage: vi.fn().mockResolvedValue({ error: 'not_found' }) } as Partial), storageStub, ).deleteMessage(user, '5', '3'), @@ -369,7 +382,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou ).toEqual({ status: 404, body: { error: 'Message not found' } }); expect( await thrownAsync(() => - new CollabController( + ctl( svc({ deleteMessage: vi.fn().mockResolvedValue({ error: 'not_owner' }) } as Partial), storageStub, ).deleteMessage(user, '5', '3'), @@ -380,7 +393,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou deleteMessage: vi.fn().mockResolvedValue({ username: 'bob' }), broadcast, } as Partial); - expect(await new CollabController(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({ + expect(await ctl(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({ success: true, }); expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:deleted', { messageId: 3, username: 'bob' }, 'sock'); @@ -396,7 +409,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou deleteMessage: vi.fn().mockResolvedValue({ username: undefined }), broadcast, } as Partial); - expect(await new CollabController(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({ + expect(await ctl(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({ success: true, }); expect(broadcast).toHaveBeenCalledWith( @@ -425,7 +438,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou const put = vi.fn().mockResolvedValue(undefined); const createMessage = vi.fn().mockReturnValue({ message: { id: 3 } }); const s = svc({ createMessage, broadcast: vi.fn(), notifyCollab: vi.fn() } as Partial); - await new CollabController(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'look' }, [ + await ctl(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'look' }, [ img('a.png'), img('b.png'), ]); @@ -444,10 +457,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou const s = svc({ createMessage } as Partial); await expect( - new CollabController(s, { put, delete: del } as never).createMessage(user, '5', { text: 'x' }, [ - img('a.png'), - img('b.png'), - ]), + ctl(s, { put, delete: del } as never).createMessage(user, '5', { text: 'x' }, [img('a.png'), img('b.png')]), ).rejects.toThrow('disk full'); // The first one is already in storage and has to come back out; the // message row was never written, so nothing would point at it. @@ -463,7 +473,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou expect( await thrownAsync(() => - new CollabController(s, { put: vi.fn().mockResolvedValue(undefined), delete: del } as never).createMessage( + ctl(s, { put: vi.fn().mockResolvedValue(undefined), delete: del } as never).createMessage( user, '5', { text: 'x', reply_to: 99 }, @@ -479,15 +489,90 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou const s = svc({ canUploadFiles: vi.fn().mockResolvedValue(false) } as Partial); expect( await thrownAsync(() => - new CollabController(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'x' }, [ - img('a.png'), - ]), + ctl(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'x' }, [img('a.png')]), ), ).toEqual({ status: 403, body: { error: 'No permission to upload files' } }); expect(put).not.toHaveBeenCalled(); }); }); + describe('demo mode', () => { + // The shared demo account is refused note files and chat images with the + // 403 the other upload routes answer it (common/demo-write.ts), and what + // multer spooled is removed before anything is committed: a committed object + // would stay, since a demo instance keeps what is deleted (StorageService.delete). + const demo = { ...user, email: 'demo@trek.app' } as User; + const refused = { + status: 403, + body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, + }; + let spoolDir: string; + const spooled = (name: string): Express.Multer.File => { + const file = path.join(spoolDir, name); + fs.writeFileSync(file, 'x'); + return { filename: name, originalname: name, size: 1, mimetype: 'image/png', path: file } as Express.Multer.File; + }; + + beforeEach(() => { + spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-collab-demo-')); + }); + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(spoolDir, { recursive: true, force: true }); + }); + + it('POST notes/:id/files: 403 for the demo account, its spooled file removed and nothing stored', async () => { + vi.stubEnv('DEMO_MODE', 'true'); + const addNoteFile = vi.fn(); + const s = svc({ addNoteFile } as Partial); + expect(await thrownAsync(() => ctl(s).addNoteFile(demo, '5', '9', spooled('a.pdf')))).toEqual(refused); + expect(fs.readdirSync(spoolDir)).toEqual([]); + expect(storageStub.put).not.toHaveBeenCalled(); + expect(addNoteFile).not.toHaveBeenCalled(); + }); + + it('POST notes/:id/files: still taken from any other account in demo mode, and from the demo account outside it', async () => { + const s = svc({ + addNoteFile: vi.fn().mockResolvedValue({ file: { id: 3 } }), + getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }), + } as Partial); + vi.stubEnv('DEMO_MODE', 'true'); + expect(await ctl(s).addNoteFile(user, '5', '9', spooled('a.pdf'))).toEqual({ file: { id: 3 } }); + vi.stubEnv('DEMO_MODE', 'false'); + expect(await ctl(s).addNoteFile(demo, '5', '9', spooled('b.pdf'))).toEqual({ file: { id: 3 } }); + expect(storageStub.put).toHaveBeenCalledTimes(2); + }); + + it('POST messages with images: 403 for the demo account, its spooled images removed and no message written', async () => { + vi.stubEnv('DEMO_MODE', 'true'); + const createMessage = vi.fn(); + const s = svc({ createMessage } as Partial); + expect( + await thrownAsync(() => + ctl(s).createMessage(demo, '5', { text: 'look' }, [spooled('a.png'), spooled('b.png')]), + ), + ).toEqual(refused); + expect(fs.readdirSync(spoolDir)).toEqual([]); + expect(storageStub.put).not.toHaveBeenCalled(); + expect(createMessage).not.toHaveBeenCalled(); + }); + + it('POST messages: images still taken from any other account in demo mode and from the demo account outside it, text from the demo account always', async () => { + const s = svc({ createMessage: vi.fn().mockResolvedValue({ message: { id: 3 } }) } as Partial); + vi.stubEnv('DEMO_MODE', 'true'); + expect(await ctl(s).createMessage(user, '5', { text: 'look' }, [spooled('a.png')])).toEqual({ + message: { id: 3 }, + }); + // Text is not an upload: the chat stays open to the demo account. + expect(await ctl(s).createMessage(demo, '5', { text: 'hi' }, undefined)).toEqual({ message: { id: 3 } }); + vi.stubEnv('DEMO_MODE', 'false'); + expect(await ctl(s).createMessage(demo, '5', { text: 'look' }, [spooled('b.png')])).toEqual({ + message: { id: 3 }, + }); + expect(storageStub.put).toHaveBeenCalledTimes(2); + }); + }); + describe('note file filter', () => { const run = (originalname: string, mimetype: string) => { let outcome: { err: Error | null; ok?: boolean } = { err: null }; @@ -588,12 +673,13 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou describe('link preview', () => { it('400 without url, maps an error result to 400, else returns the preview', async () => { - expect( - await thrownAsync(() => new CollabController(svc(), storageStub).linkPreview(user, '5', undefined)), - ).toEqual({ status: 400, body: { error: 'URL is required' } }); + expect(await thrownAsync(() => ctl(svc(), storageStub).linkPreview(user, '5', undefined))).toEqual({ + status: 400, + body: { error: 'URL is required' }, + }); expect( await thrownAsync(() => - new CollabController( + ctl( svc({ linkPreview: vi.fn().mockResolvedValue({ error: 'bad url' }) } as Partial), storageStub, ).linkPreview(user, '5', 'http://x'), @@ -602,7 +688,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou const s = svc({ linkPreview: vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' }), } as Partial); - expect(await new CollabController(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({ + expect(await ctl(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({ title: 'T', description: null, image: null, @@ -616,7 +702,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou .fn() .mockResolvedValue({ title: null, description: null, image: null, url: 'http://x', rateLimited: true }), } as Partial); - expect(await thrownAsync(() => new CollabController(s, storageStub).linkPreview(user, '5', 'http://x'))).toEqual({ + expect(await thrownAsync(() => ctl(s, storageStub).linkPreview(user, '5', 'http://x'))).toEqual({ status: 429, body: { error: 'Too many requests' }, }); @@ -624,17 +710,13 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou it('passes the caller through, so the budget is charged per user and not per instance', async () => { const linkPreview = vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' }); - await new CollabController(svc({ linkPreview } as Partial), storageStub).linkPreview( - user, - '5', - 'http://x', - ); + await ctl(svc({ linkPreview } as Partial), storageStub).linkPreview(user, '5', 'http://x'); expect(linkPreview).toHaveBeenCalledWith('http://x', user.id); }); it('falls back to a null preview when the service throws', async () => { const s = svc({ linkPreview: vi.fn().mockRejectedValue(new Error('network')) } as Partial); - expect(await new CollabController(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({ + expect(await ctl(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({ title: null, description: null, image: null, diff --git a/server/tests/unit/nest/journey.controller.test.ts b/server/tests/unit/nest/journey.controller.test.ts index 8b5910ff3..c151f43a3 100644 --- a/server/tests/unit/nest/journey.controller.test.ts +++ b/server/tests/unit/nest/journey.controller.test.ts @@ -1,6 +1,7 @@ import { ADDON_IDS } from '../../../src/addons'; import { AddonGuard } from '../../../src/nest/addons/addon.guard'; import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import type { JourneyBookService } from '../../../src/nest/journey/journey-book.service'; import type { JourneyPhotoCaptureService } from '../../../src/nest/journey/journey-photo-capture.service'; import type { JourneyPhotoLinksService } from '../../../src/nest/journey/journey-photo-links.service'; @@ -13,7 +14,10 @@ import type { User } from '../../../src/types'; import { HttpException } from '@nestjs/common'; import type { Response } from 'express'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User; @@ -61,6 +65,8 @@ function ctl( books as JourneyBookService, captureBackfillStub, photoLinks as JourneyPhotoLinksService, + // The live env: a case about demo mode stubs DEMO_MODE, every other runs outside it. + new RuntimeEnvService(), ); } @@ -1055,6 +1061,75 @@ describe('JourneyController', () => { }); }); +describe('JourneyController in demo mode', () => { + // The shared demo account is refused every journey upload with the 403 the + // other upload routes answer it (common/demo-write.ts), and what multer + // spooled is removed before anything is committed: a committed object would + // stay, since a demo instance keeps what is deleted (StorageService.delete). + const demo = { ...user, email: 'demo@trek.app' } as User; + let spoolDir: string; + const spooled = (name: string): Express.Multer.File => { + const file = path.join(spoolDir, name); + fs.writeFileSync(file, 'x'); + return { filename: name, originalname: name, path: file } as Express.Multer.File; + }; + /** A service that takes every upload, so a case shows what the demo check alone decides. */ + const takesAll = () => + svc({ + addPhoto: vi.fn().mockReturnValue({ id: 5 }), + immichAutoUploadEnabled: vi.fn().mockReturnValue(false), + uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 7 }]), + updateJourney: vi.fn().mockReturnValue({ id: 9 }), + } as Partial); + const routes: Array<[string, (c: JourneyController, u: User) => Promise]> = [ + ['POST /entries/:entryId/photos', (c, u) => c.uploadEntryPhotos(u, '3', [spooled('a.jpg'), spooled('b.jpg')], {})], + [ + 'POST /entries/:entryId/video', + (c, u) => c.uploadEntryVideo(u, '3', { video: [spooled('v.mp4')], poster: [spooled('p.jpg')] }, {}), + ], + ['POST /:id/gallery/photos', (c, u) => c.uploadGalleryPhotos(u, '9', [spooled('g.jpg')])], + [ + 'POST /:id/gallery/video', + (c, u) => c.uploadGalleryVideo(u, '9', { video: [spooled('v.mp4')], poster: [spooled('p.jpg')] }, {}), + ], + ['POST /:id/cover', (c, u) => c.cover(u, '9', spooled('c.jpg'))], + ]; + + beforeEach(() => { + spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-journey-demo-')); + }); + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(spoolDir, { recursive: true, force: true }); + }); + + it.each(routes)('%s: 403 for the demo account, its spooled parts removed and nothing stored', async (_, call) => { + vi.stubEnv('DEMO_MODE', 'true'); + const service = takesAll(); + expect(await thrownAsync(() => call(ctl(service), demo))).toEqual({ + status: 403, + body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, + }); + expect(fs.readdirSync(spoolDir)).toEqual([]); + expect(storageStub.put).not.toHaveBeenCalled(); + expect(service.addPhoto).not.toHaveBeenCalled(); + expect(service.uploadGalleryPhotos).not.toHaveBeenCalled(); + expect(service.updateJourney).not.toHaveBeenCalled(); + }); + + it.each(routes)('%s: still taken from any other account in demo mode', async (_, call) => { + vi.stubEnv('DEMO_MODE', 'true'); + await call(ctl(takesAll()), user); + expect(storageStub.put).toHaveBeenCalled(); + }); + + it.each(routes)('%s: still taken from the demo account outside demo mode', async (_, call) => { + vi.stubEnv('DEMO_MODE', 'false'); + await call(ctl(takesAll()), demo); + expect(storageStub.put).toHaveBeenCalled(); + }); +}); + describe('JourneyPublicController', () => { it('is addon-gated like the authenticated surface', () => { // Turning the Journey addon off is meant to take the feature away; without diff --git a/wiki/Demo-Mode.md b/wiki/Demo-Mode.md index 335655007..2cf692f5f 100644 --- a/wiki/Demo-Mode.md +++ b/wiki/Demo-Mode.md @@ -40,7 +40,7 @@ The demo user can work with the shared example trips like any member, but the fo - **Password change**: returns 403. - **Account deletion**: returns 403. - **MFA enrollment or removal**: returns 403. The 2FA card in Settings says it is not available in demo mode, and the Passkeys card is hidden. -- **File uploads**: avatar uploads, trip cover uploads, and document/photo file attachments are blocked and return 403. +- **File uploads**: avatars, trip and collection covers, place images, trip files, journey photos, videos and covers, and the files and images attached to collab notes and chat messages are blocked and return 403. A chat message without an image still posts. [Document-Sync](Document-Sync) is not covered by this block: all its stores ship switched off, but once an administrator switches one on, the demo account can bind a trip it owns in the trip's file manager and pull documents from that store into the trip files, also through the `sync_trip_documents` MCP tool. - **All MCP write tools**: create, update, and delete operations via the MCP API are blocked for the demo user. Registration is also disabled while demo mode is active; visitors cannot create new accounts.