Files
TREK/server/tests/unit/nest/collab.controller.test.ts
T
Maurice 2372dde716 fix(demo): journey and collab uploads are refused for the demo account too
Six upload routes refused the shared demo account in demo mode; journey photos, videos, gallery media and covers, collab note files and chat images did not, so any visitor could put images in front of every other one for the hour. They answer the same 403 now, checked in the handler after the upload is read, and a text-only chat message still goes through. The Demo Mode wiki page lists them, and says document sync is not covered.
2026-10-25 00:46:24 +00:00

728 lines
32 KiB
TypeScript

import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
import {
CollabController,
collabChatImageFilter,
collabNoteFileFilter,
} from '../../../src/nest/collab/collab.controller';
import type { ChatMessageInput, CollabService } from '../../../src/nest/collab/collab.service';
import type { TripWriter } from '../../../src/nest/common/trip-writer';
import { TripAccessGuard, TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
import type { StorageService } from '../../../src/nest/storage/storage.service';
import type { User } from '../../../src/types';
import { HttpException } from '@nestjs/common';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
/** The writer a route builds for its request, as the stub hands it back. */
const restWriter = (socketId?: string) => ({ userId: 1, role: 'user', socketId });
function svc(o: Partial<CollabService> = {}): CollabService {
const s = {
verifyTripAccess: vi.fn().mockResolvedValue({ user_id: 1 }),
canEdit: vi.fn().mockResolvedValue(true),
canUploadFiles: vi.fn().mockResolvedValue(true),
broadcast: vi.fn(),
notifyCollab: vi.fn(),
restWriter: vi.fn((_tripId: string, _user: User, socketId: string | undefined) => restWriter(socketId)),
...o,
} as unknown as CollabService;
// The two posting use cases over the stubbed building blocks, so a case can stub
// createNote or createMessage as before. What the real ones send and notify is
// held by collab.service.test.ts and the broadcast parity suite.
const post = s as unknown as Record<'postNote' | 'postMessage', unknown>;
post.postNote ??= vi.fn((tripId: string, data: Parameters<CollabService['createNote']>[2], w: TripWriter) =>
s.createNote(tripId, w.userId, data),
);
post.postMessage ??= vi.fn((tripId: string, input: ChatMessageInput, w: TripWriter) =>
s.createMessage(tripId, w.userId, input.text, input.replyTo ?? null, input.files ?? [], input.origin ?? null),
);
return s;
}
const storageStub = {
put: vi.fn().mockResolvedValue(undefined),
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<unknown>): Promise<{ status: number; body: unknown }> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
beforeEach(() => vi.clearAllMocks());
describe('CollabController (parity with the legacy /api/trips/:tripId/collab route)', () => {
describe('notes', () => {
it('GET lists', async () => {
const s = svc({ listNotes: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial<CollabService>);
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<CollabService>);
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');
expect(s.postNote).toHaveBeenCalledWith('5', fields, restWriter('sock'));
// Nothing goes out from the route itself, or it would go out twice.
expect(s.broadcast).not.toHaveBeenCalled();
});
it('PUT 404 when missing, else updates + broadcasts', async () => {
expect(
await thrownAsync(() =>
ctl(svc({ updateNote: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), 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<CollabService>);
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');
});
it('DELETE 404 when missing, else success + broadcasts', async () => {
expect(
await thrownAsync(() =>
ctl(svc({ deleteNote: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), 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<CollabService>);
expect(await ctl(s, storageStub).deleteNote(user, '5', '9', 'sock')).toEqual({ success: true });
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:deleted', { noteId: 9 }, 'sock');
});
});
describe('note files', () => {
const file = { filename: 'a.pdf' } as Express.Multer.File;
it('403 without file_upload, 400 without file, 404 unknown note, else commits + returns result', async () => {
expect(
await thrownAsync(() =>
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<CollabService>), storageStub).addNoteFile(
user,
'5',
'9',
file,
),
),
).toEqual({ status: 404, body: { error: 'Note not found' } });
const broadcast = vi.fn();
const s = svc({
addNoteFile: vi.fn().mockResolvedValue({ file: { id: 3 } }),
getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }),
broadcast,
} as Partial<CollabService>);
expect(await ctl(s, storageStub).addNoteFile(user, '5', '9', file, 'sock')).toEqual({
file: { id: 3 },
});
expect(storageStub.put).toHaveBeenCalledWith('files', 'a.pdf', { tmpPath: undefined });
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:updated', { note: { id: 9 } }, 'sock');
});
it('reclaims the upload on every refusal, spooled or already committed', async () => {
// multer has written the file before any check runs, and nothing sweeps
// orphans, so a loop of rejected POSTs would otherwise fill the disk.
const spool = path.join(os.tmpdir(), `trek-collab-${Date.now()}-${Math.random().toString(16).slice(2)}.pdf`);
fs.writeFileSync(spool, 'x');
const spooled = { filename: 'a.pdf', path: spool } as Express.Multer.File;
expect(
await thrownAsync(() =>
ctl(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(
user,
'5',
'9',
spooled,
),
),
).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
expect(fs.existsSync(spool)).toBe(false);
const spool2 = path.join(os.tmpdir(), `trek-collab-${Date.now()}-${Math.random().toString(16).slice(2)}.pdf`);
fs.writeFileSync(spool2, 'x');
expect(
await thrownAsync(() =>
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);
// Past the commit the spool file is gone, so the final object is what has
// to go instead.
await thrownAsync(() =>
ctl(svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).addNoteFile(
user,
'5',
'9',
file,
),
);
expect(storageStub.delete).toHaveBeenCalledWith('files', 'a.pdf');
});
it('DELETE file 404 when missing, else success', async () => {
expect(
await thrownAsync(() =>
ctl(
svc({ deleteNoteFile: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
storageStub,
).deleteNoteFile(user, '5', '9', '3'),
),
).toEqual({ status: 404, body: { error: 'File not found' } });
const s = svc({
deleteNoteFile: vi.fn().mockResolvedValue(true),
getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }),
broadcast: vi.fn(),
} as Partial<CollabService>);
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<CollabService>);
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(() =>
ctl(
svc({ votePoll: vi.fn().mockResolvedValue({ error: 'not_found' }) } as Partial<CollabService>),
storageStub,
).votePoll(user, '5', '7', { option_index: 0 }),
),
).toEqual({ status: 404, body: { error: 'Poll not found' } });
expect(
await thrownAsync(() =>
ctl(
svc({ votePoll: vi.fn().mockResolvedValue({ error: 'closed' }) } as Partial<CollabService>),
storageStub,
).votePoll(user, '5', '7', { option_index: 0 }),
),
).toEqual({ status: 400, body: { error: 'Poll is closed' } });
expect(
await thrownAsync(() =>
ctl(
svc({ votePoll: vi.fn().mockResolvedValue({ error: 'invalid_index' }) } as Partial<CollabService>),
storageStub,
).votePoll(user, '5', '7', { option_index: 9 }),
),
).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<CollabService>);
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');
});
it('close 404 when missing, else broadcasts', async () => {
expect(
await thrownAsync(() =>
ctl(svc({ closePoll: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), 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<CollabService>);
expect(await ctl(s, storageStub).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } });
});
it('delete 404 when missing, else success', async () => {
expect(
await thrownAsync(() =>
ctl(svc({ deletePoll: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), 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<CollabService>);
expect(await ctl(s, storageStub).deletePoll(user, '5', '7')).toEqual({ success: true });
});
});
describe('messages', () => {
// Async since the route took its multipart form: it awaits the storage
// commit, so every case here goes through thrownAsync.
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(() => ctl(svc(), storageStub).createMessage(user, '5', { text: ' ' }, undefined)),
).toEqual({ status: 400, body: { error: 'Message text 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(() =>
ctl(
svc({ createMessage: vi.fn().mockResolvedValue({ error: 'reply_not_found' }) } as Partial<CollabService>),
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<CollabService>);
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();
});
it('refuses a caller who cannot reach the trip or cannot write, from inside the handler', async () => {
// The decorators had to go because of the multipart body, so these two
// refusals are the only thing standing in front of the route now.
expect(
await thrownAsync(() =>
ctl(
svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
storageStub,
).createMessage(user, '5', { text: 'hi' }, undefined),
),
).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(
await thrownAsync(() =>
ctl(svc({ canEdit: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), storageStub).createMessage(
user,
'5',
{ text: 'hi' },
undefined,
),
),
).toEqual({ status: 403, body: { error: 'No permission' } });
});
it('react 404 unknown, else broadcasts reactions (empty emoji now 400s in the Zod pipe)', async () => {
expect(
await thrownAsync(() =>
ctl(
svc({ reactMessage: vi.fn().mockResolvedValue({ found: false, reactions: [] }) } as Partial<CollabService>),
storageStub,
).react(user, '5', '3', { emoji: '👍' }),
),
).toEqual({ status: 404, body: { error: 'Message not found' } });
const broadcast = vi.fn();
const s = svc({
reactMessage: vi.fn().mockResolvedValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] }),
broadcast,
} as Partial<CollabService>);
expect(await ctl(s, storageStub).react(user, '5', '3', { emoji: '👍' }, 'sock')).toEqual({
reactions: [{ emoji: '👍', count: 1 }],
});
expect(broadcast).toHaveBeenCalledWith(
'5',
'collab:message:reacted',
{ messageId: 3, reactions: [{ emoji: '👍', count: 1 }] },
'sock',
);
});
it('delete maps not_found (404) / not_owner (403), else success with username', async () => {
expect(
await thrownAsync(() =>
ctl(
svc({ deleteMessage: vi.fn().mockResolvedValue({ error: 'not_found' }) } as Partial<CollabService>),
storageStub,
).deleteMessage(user, '5', '3'),
),
).toEqual({ status: 404, body: { error: 'Message not found' } });
expect(
await thrownAsync(() =>
ctl(
svc({ deleteMessage: vi.fn().mockResolvedValue({ error: 'not_owner' }) } as Partial<CollabService>),
storageStub,
).deleteMessage(user, '5', '3'),
),
).toEqual({ status: 403, body: { error: 'You can only delete your own messages' } });
const broadcast = vi.fn();
const s = svc({
deleteMessage: vi.fn().mockResolvedValue({ username: 'bob' }),
broadcast,
} as Partial<CollabService>);
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');
});
it("U5: deleteMessage broadcast falls back to the caller's own username when the service's username is undefined", async () => {
const broadcast = vi.fn();
// `CollabService.deleteMessage` returned `{ username: undefined }` for
// years (findInTrip never joined users). It now reads the author's name,
// but the controller keeps its `result.username || user.username`
// fallback, which is correct because only the owner may delete.
const s = svc({
deleteMessage: vi.fn().mockResolvedValue({ username: undefined }),
broadcast,
} as Partial<CollabService>);
expect(await ctl(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
success: true,
});
expect(broadcast).toHaveBeenCalledWith(
'5',
'collab:message:deleted',
{ messageId: 3, username: user.username },
'sock',
);
});
});
// The decorators, not the handler body. Constructing the controller directly,
// which every test above does, runs no guard at all — so removing the trip check
// again would leave this file green. It shipped without one once.
describe('chat images on a message', () => {
const img = (name: string) =>
({
filename: `stored-${name}`,
originalname: name,
size: 10,
mimetype: 'image/png',
path: `/tmp/${name}`,
}) as never;
it('commits every image to storage and hands the service what it needs to row them', async () => {
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<CollabService>);
await ctl(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'look' }, [
img('a.png'),
img('b.png'),
]);
expect(put.mock.calls.map((c) => c[1])).toEqual(['stored-a.png', 'stored-b.png']);
expect(createMessage.mock.calls[0][4]).toEqual([
{ filename: 'stored-a.png', originalname: 'a.png', size: 10, mimetype: 'image/png' },
{ filename: 'stored-b.png', originalname: 'b.png', size: 10, mimetype: 'image/png' },
]);
});
it('takes back what it already committed when a later image fails to store', async () => {
const del = vi.fn().mockResolvedValue(undefined);
const put = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('disk full'));
const createMessage = vi.fn();
const s = svc({ createMessage } as Partial<CollabService>);
await expect(
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.
expect(del.mock.calls.map((c) => c[1])).toEqual(['stored-a.png']);
expect(createMessage).not.toHaveBeenCalled();
});
it('drops the stored images again when the reply target turns out to be gone', async () => {
const del = vi.fn().mockResolvedValue(undefined);
const s = svc({
createMessage: vi.fn().mockResolvedValue({ error: 'reply_not_found' }),
} as Partial<CollabService>);
expect(
await thrownAsync(() =>
ctl(s, { put: vi.fn().mockResolvedValue(undefined), delete: del } as never).createMessage(
user,
'5',
{ text: 'x', reply_to: 99 },
[img('a.png')],
),
),
).toEqual({ status: 400, body: { error: 'Reply target message not found' } });
expect(del.mock.calls.map((c) => c[1])).toEqual(['stored-a.png']);
});
it('refuses an image from someone who may write but may not upload', async () => {
const put = vi.fn();
const s = svc({ canUploadFiles: vi.fn().mockResolvedValue(false) } as Partial<CollabService>);
expect(
await thrownAsync(() =>
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<CollabService>);
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<CollabService>);
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<CollabService>);
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<CollabService>);
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 };
collabNoteFileFilter!(
{} as never,
{ originalname, mimetype } as never,
((err: Error | null, ok?: boolean) => {
outcome = { err, ok };
}) as never,
);
return outcome;
};
it('takes an ordinary attachment', async () => {
expect(run('itinerary.pdf', 'application/pdf').ok).toBe(true);
});
it('refuses the spellings the download route would serve as a document', async () => {
expect(run('pwn.html', 'text/html').err).toBeInstanceOf(Error);
expect(run('pwn.txt', 'image/svg+xml').err).toBeInstanceOf(Error);
expect(run('pwn.txt', 'application/javascript').err).toBeInstanceOf(Error);
});
});
describe('chat image filter', () => {
const run = (originalname: string, mimetype: string) => {
let outcome: { err: Error | null; ok?: boolean } = { err: null };
collabChatImageFilter!(
{} as never,
{ originalname, mimetype } as never,
((err: Error | null, ok?: boolean) => {
outcome = { err, ok };
}) as never,
);
return outcome;
};
it('accepts a real image', async () => {
expect(run('holiday.jpg', 'image/jpeg').ok).toBe(true);
expect(run('map.PNG', 'image/png').ok).toBe(true);
});
it('refuses a type it does not serve', async () => {
expect(run('notes.pdf', 'application/pdf').err).toBeInstanceOf(Error);
});
it('refuses a name whose extension disagrees with the type it claims', async () => {
// The mimetype is the header the client wrote. The stored name keeps the
// extension of the name the client sent, and the download route decides
// what to serve from that extension and sends it inline, so believing the
// header alone served attacker HTML on our own origin.
for (const name of ['pwn.html', 'pwn.svg', 'pwn.js', 'pwn.htm', 'pwn']) {
const out = run(name, 'image/png');
expect(out.err, name).toBeInstanceOf(Error);
expect((out.err as Error & { statusCode?: number }).statusCode).toBe(400);
}
});
});
describe('link preview guard chain', () => {
const guardsOn = (target: object): unknown[] => (Reflect.getMetadata('__guards__', target) as unknown[]) ?? [];
it('resolves the trip and refuses a caller who cannot reach it', async () => {
expect(guardsOn(CollabController.prototype.linkPreview)).toContain(TripAccessGuard);
});
it('sits behind the same authentication as the rest of the controller', async () => {
expect(guardsOn(CollabController)).toContain(JwtAuthGuard);
});
it('demands no write permission, matching the other read routes', async () => {
// It is requested while rendering a message or a note, never while writing
// one. Requiring collab_edit would strip previews from a trip whose owner
// narrowed that right, for people who may still read the chat.
for (const handler of [
CollabController.prototype.linkPreview,
CollabController.prototype.listMessages,
CollabController.prototype.listNotes,
]) {
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBeUndefined();
}
// The write siblings do demand it, so this is a deliberate split, not an omission.
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, CollabController.prototype.createNote)).toBe('collab_edit');
});
it('the multipart write routes carry no guard decorators, and check inside instead', async () => {
// Guards run before the interceptor, so a refusal goes out while the client
// is still streaming the body and the socket dies as ECONNRESET rather than
// carrying the error envelope. Both of these therefore look unguarded here
// and do requireTrip plus the permission check in the handler; the test
// below proves createMessage actually refuses.
for (const handler of [CollabController.prototype.createMessage, CollabController.prototype.addNoteFile]) {
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBeUndefined();
expect(guardsOn(handler)).not.toContain(TripAccessGuard);
}
});
});
describe('link preview', () => {
it('400 without url, maps an error result to 400, else returns the preview', async () => {
expect(await thrownAsync(() => ctl(svc(), storageStub).linkPreview(user, '5', undefined))).toEqual({
status: 400,
body: { error: 'URL is required' },
});
expect(
await thrownAsync(() =>
ctl(
svc({ linkPreview: vi.fn().mockResolvedValue({ error: 'bad url' }) } as Partial<CollabService>),
storageStub,
).linkPreview(user, '5', 'http://x'),
),
).toEqual({ status: 400, body: { error: 'bad url' } });
const s = svc({
linkPreview: vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' }),
} as Partial<CollabService>);
expect(await ctl(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
title: 'T',
description: null,
image: null,
url: 'http://x',
});
});
it('maps an exhausted preview budget to 429, not to the 400 a refused URL gets', async () => {
const s = svc({
linkPreview: vi
.fn()
.mockResolvedValue({ title: null, description: null, image: null, url: 'http://x', rateLimited: true }),
} as Partial<CollabService>);
expect(await thrownAsync(() => ctl(s, storageStub).linkPreview(user, '5', 'http://x'))).toEqual({
status: 429,
body: { error: 'Too many requests' },
});
});
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 ctl(svc({ linkPreview } as Partial<CollabService>), 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<CollabService>);
expect(await ctl(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
title: null,
description: null,
image: null,
url: 'http://x',
});
});
});
});