/** * Collab (notes, polls, messages, reactions) integration tests. * Covers COLLAB-001 to COLLAB-027. * * Note: File upload to collab notes (COLLAB-005/006/007) requires physical file I/O. * Link preview (COLLAB-025/026) would need fetch mocking — skipped here. */ import { buildApp } from '../../src/bootstrap'; import { db as testDb } from '../../src/db/database'; import { CollabService } from '../../src/nest/collab/collab.service'; import { authCookie, generateToken } from '../helpers/auth'; import { createUser, createTrip, addTripMember } from '../helpers/factories'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import type { INestApplication } from '@nestjs/common'; import type { Application } from 'express'; import fs from 'fs'; import path from 'path'; import request from 'supertest'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); // Spy on the DI-native service's linkPreview so the SSRF-guarded fetch never // runs; the rest of CollabService exercises its real SQL through the container. // The original is kept so the guard cases below can put it back for one call — // mockRestore would drop the spy for every test declared after them. const realLinkPreview = CollabService.prototype.linkPreview; const linkPreviewSpy = vi .spyOn(CollabService.prototype, 'linkPreview') .mockResolvedValue({ title: null, description: null, image: null, url: '' }); let nestApp: INestApplication; let app: Application; const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf'); // Ensure uploads/files dir exists for collab file uploads const uploadsDir = path.join(__dirname, '../../uploads/files'); beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); }); beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); }); afterAll(async () => { await nestApp.close(); testDb.close(); }); // ───────────────────────────────────────────────────────────────────────────── // Collab Notes // ───────────────────────────────────────────────────────────────────────────── describe('Collab notes', () => { it('COLLAB-001 — POST /collab/notes creates a note', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'Packing Ideas', content: 'Bring sunscreen', category: 'Planning' }); expect(res.status).toBe(201); expect(res.body.note.title).toBe('Packing Ideas'); expect(res.body.note.content).toBe('Bring sunscreen'); }); it('COLLAB-001 — POST without title returns 400', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ content: 'No title' }); expect(res.status).toBe(400); }); it('COLLAB-001 — non-member cannot create collab note', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id); const res = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(other.id)) .send({ title: 'Sneaky note' }); expect(res.status).toBe(404); }); it('COLLAB-002 — GET /collab/notes returns all notes', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'Note A' }); await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'Note B' }); const res = await request(app).get(`/api/trips/${trip.id}/collab/notes`).set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.notes).toHaveLength(2); }); it('COLLAB-003 — PUT /collab/notes/:id updates a note', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'Old Title', content: 'Old content' }); const noteId = create.body.note.id; const res = await request(app) .put(`/api/trips/${trip.id}/collab/notes/${noteId}`) .set('Cookie', authCookie(user.id)) .send({ title: 'New Title', content: 'New content', pinned: true }); expect(res.status).toBe(200); expect(res.body.note.title).toBe('New Title'); expect(res.body.note.pinned).toBe(1); }); it('COLLAB-003 — PUT non-existent note returns 404', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .put(`/api/trips/${trip.id}/collab/notes/99999`) .set('Cookie', authCookie(user.id)) .send({ title: 'Updated' }); expect(res.status).toBe(404); }); it('COLLAB-004 — DELETE /collab/notes/:id removes note', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'To Delete' }); const noteId = create.body.note.id; const del = await request(app) .delete(`/api/trips/${trip.id}/collab/notes/${noteId}`) .set('Cookie', authCookie(user.id)); expect(del.status).toBe(200); expect(del.body.success).toBe(true); const list = await request(app).get(`/api/trips/${trip.id}/collab/notes`).set('Cookie', authCookie(user.id)); expect(list.body.notes).toHaveLength(0); }); it('COLLAB-005 — POST /collab/notes/:id/files uploads a file to a note', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'Note with file' }); const noteId = create.body.note.id; const upload = await request(app) .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) .set('Cookie', authCookie(user.id)) .attach('file', FIXTURE_PDF); expect(upload.status).toBe(201); expect(upload.body.file).toBeDefined(); }); it('COLLAB-006 — uploading blocked extension to note is rejected', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'Note' }); const noteId = create.body.note.id; // Create a temp .svg file const svgPath = path.join(uploadsDir, 'collab_blocked.svg'); fs.writeFileSync(svgPath, ''); try { const res = await request(app) .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) .set('Cookie', authCookie(user.id)) .attach('file', svgPath); expect(res.status).toBe(400); } finally { if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath); } }); it('COLLAB-007 — DELETE /collab/notes/:noteId/files/:fileId removes file from note', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'Note with file' }); const noteId = create.body.note.id; const upload = await request(app) .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) .set('Cookie', authCookie(user.id)) .attach('file', FIXTURE_PDF); const fileId = upload.body.file.id; const del = await request(app) .delete(`/api/trips/${trip.id}/collab/notes/${noteId}/files/${fileId}`) .set('Cookie', authCookie(user.id)); expect(del.status).toBe(200); expect(del.body.success).toBe(true); }); it('COLLAB-P01 — attachment filename is stored bare (category + name; no files/ prefix)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'Bare name' }); const noteId = create.body.note.id; const upload = await request(app) .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) .set('Cookie', authCookie(user.id)) .attach('file', FIXTURE_PDF); expect(upload.status).toBe(201); const list = await request(app).get(`/api/trips/${trip.id}/collab/notes`).set('Cookie', authCookie(user.id)); const note = list.body.notes.find((n: any) => n.id === noteId); expect(note.attachments[0].filename).toMatch(/^[0-9a-f-]{36}\.pdf$/); expect(note.attachments[0].url).toMatch(/^\/api\/trips\/\d+\/files\/\d+\/download$/); // The bytes live in uploads/files under the bare name. expect(fs.existsSync(path.join(uploadsDir, note.attachments[0].filename))).toBe(true); }); it('COLLAB-P02 — deleting a note file removes its bytes from uploads/files', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'Delete file bytes' }); const noteId = create.body.note.id; const upload = await request(app) .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) .set('Cookie', authCookie(user.id)) .attach('file', FIXTURE_PDF); const fileId = upload.body.file.id; const diskName = path.basename(upload.body.file.filename); expect(fs.existsSync(path.join(uploadsDir, diskName))).toBe(true); const del = await request(app) .delete(`/api/trips/${trip.id}/collab/notes/${noteId}/files/${fileId}`) .set('Cookie', authCookie(user.id)); expect(del.status).toBe(200); expect(fs.existsSync(path.join(uploadsDir, diskName))).toBe(false); }); it('COLLAB-P03 — deleting a note removes its attachments’ bytes from uploads/files', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'Delete note bytes' }); const noteId = create.body.note.id; const upload = await request(app) .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) .set('Cookie', authCookie(user.id)) .attach('file', FIXTURE_PDF); const diskName = path.basename(upload.body.file.filename); expect(fs.existsSync(path.join(uploadsDir, diskName))).toBe(true); const del = await request(app) .delete(`/api/trips/${trip.id}/collab/notes/${noteId}`) .set('Cookie', authCookie(user.id)); expect(del.status).toBe(200); expect(fs.existsSync(path.join(uploadsDir, diskName))).toBe(false); }); it('COLLAB-028 — uploaded note file URL uses authenticated download path, not /uploads/', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'URL check' }); const noteId = create.body.note.id; const upload = await request(app) .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) .set('Cookie', authCookie(user.id)) .attach('file', FIXTURE_PDF); expect(upload.status).toBe(201); const fileUrl = upload.body.file.url; expect(fileUrl).toMatch(/^\/api\/trips\/\d+\/files\/\d+\/download$/); expect(fileUrl).not.toContain('/uploads/'); }); it('COLLAB-029 — note attachments in listing use authenticated download URLs', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'List URL check' }); const noteId = create.body.note.id; await request(app) .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) .set('Cookie', authCookie(user.id)) .attach('file', FIXTURE_PDF); const list = await request(app).get(`/api/trips/${trip.id}/collab/notes`).set('Cookie', authCookie(user.id)); expect(list.status).toBe(200); const note = list.body.notes.find((n: any) => n.id === noteId); expect(note.attachments.length).toBe(1); expect(note.attachments[0].url).toMatch(/^\/api\/trips\/\d+\/files\/\d+\/download$/); expect(note.attachments[0].url).not.toContain('/uploads/'); }); it('COLLAB-030 — note file is downloadable via files endpoint with ephemeral token', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'Downloadable note' }); const noteId = create.body.note.id; const upload = await request(app) .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) .set('Cookie', authCookie(user.id)) .attach('file', FIXTURE_PDF); const fileUrl = upload.body.file.url; // Obtain an ephemeral resource token (same flow as getAuthUrl on the client) const tokenRes = await request(app) .post('/api/auth/resource-token') .set('Cookie', authCookie(user.id)) .send({ purpose: 'download' }); expect(tokenRes.status).toBe(200); const { token } = tokenRes.body; // Download with ?token= should succeed const dl = await request(app).get(`${fileUrl}?token=${token}`); expect(dl.status).toBe(200); }); it('COLLAB-031 — note file download without auth returns 401', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/notes`) .set('Cookie', authCookie(user.id)) .send({ title: 'Auth required note' }); const noteId = create.body.note.id; const upload = await request(app) .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) .set('Cookie', authCookie(user.id)) .attach('file', FIXTURE_PDF); const fileUrl = upload.body.file.url; // Download without any auth should fail const dl = await request(app).get(fileUrl); expect(dl.status).toBe(401); }); }); // ───────────────────────────────────────────────────────────────────────────── // Polls // ───────────────────────────────────────────────────────────────────────────── describe('Polls', () => { it('COLLAB-008 — POST /collab/polls creates a poll', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .post(`/api/trips/${trip.id}/collab/polls`) .set('Cookie', authCookie(user.id)) .send({ question: 'Where to eat?', options: ['Pizza', 'Sushi', 'Tacos'] }); expect(res.status).toBe(201); expect(res.body.poll.question).toBe('Where to eat?'); expect(res.body.poll.options).toHaveLength(3); }); it('COLLAB-008 — POST without question returns 400', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .post(`/api/trips/${trip.id}/collab/polls`) .set('Cookie', authCookie(user.id)) .send({ options: ['A', 'B'] }); expect(res.status).toBe(400); }); it('COLLAB-009 — GET /collab/polls returns polls', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await request(app) .post(`/api/trips/${trip.id}/collab/polls`) .set('Cookie', authCookie(user.id)) .send({ question: 'Beach or mountains?', options: ['Beach', 'Mountains'] }); const res = await request(app).get(`/api/trips/${trip.id}/collab/polls`).set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.polls).toHaveLength(1); }); it('COLLAB-010 — POST /collab/polls/:id/vote casts a vote', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/polls`) .set('Cookie', authCookie(user.id)) .send({ question: 'Restaurant?', options: ['Italian', 'French'] }); const pollId = create.body.poll.id; const vote = await request(app) .post(`/api/trips/${trip.id}/collab/polls/${pollId}/vote`) .set('Cookie', authCookie(user.id)) .send({ option_index: 0 }); expect(vote.status).toBe(200); expect(vote.body.poll).toBeDefined(); }); it('COLLAB-011 — PUT /collab/polls/:id/close closes a poll', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/polls`) .set('Cookie', authCookie(user.id)) .send({ question: 'Hotel?', options: ['Budget', 'Luxury'] }); const pollId = create.body.poll.id; const close = await request(app) .put(`/api/trips/${trip.id}/collab/polls/${pollId}/close`) .set('Cookie', authCookie(user.id)); expect(close.status).toBe(200); expect(close.body.poll.is_closed).toBe(true); }); it('COLLAB-012 — cannot vote on closed poll', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/polls`) .set('Cookie', authCookie(user.id)) .send({ question: 'Closed?', options: ['Yes', 'No'] }); const pollId = create.body.poll.id; await request(app).put(`/api/trips/${trip.id}/collab/polls/${pollId}/close`).set('Cookie', authCookie(user.id)); const vote = await request(app) .post(`/api/trips/${trip.id}/collab/polls/${pollId}/vote`) .set('Cookie', authCookie(user.id)) .send({ option_index: 0 }); expect(vote.status).toBe(400); }); it('COLLAB-013 — DELETE /collab/polls/:id removes poll', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/collab/polls`) .set('Cookie', authCookie(user.id)) .send({ question: 'Delete me?', options: ['Yes', 'No'] }); const pollId = create.body.poll.id; const del = await request(app) .delete(`/api/trips/${trip.id}/collab/polls/${pollId}`) .set('Cookie', authCookie(user.id)); expect(del.status).toBe(200); expect(del.body.success).toBe(true); }); }); // ───────────────────────────────────────────────────────────────────────────── // Messages // ───────────────────────────────────────────────────────────────────────────── describe('Messages', () => { it('COLLAB-014 — POST /collab/messages sends a message', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(user.id)) .send({ text: 'Hello, team!' }); expect(res.status).toBe(201); expect(res.body.message.text).toBe('Hello, team!'); }); it('COLLAB-014 — POST without text returns 400', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(user.id)) .send({ text: '' }); expect(res.status).toBe(400); }); it('COLLAB-014 — non-member cannot send message', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id); const res = await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(other.id)) .send({ text: 'Unauthorized' }); expect(res.status).toBe(404); }); it('COLLAB-015 — GET /collab/messages returns messages in order', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(user.id)) .send({ text: 'First message' }); await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(user.id)) .send({ text: 'Second message' }); const res = await request(app).get(`/api/trips/${trip.id}/collab/messages`).set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.messages.length).toBeGreaterThanOrEqual(2); }); it('COLLAB-016 — POST /collab/messages with reply_to links reply', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const parent = await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(user.id)) .send({ text: 'Original' }); const parentId = parent.body.message.id; const reply = await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(user.id)) .send({ text: 'Reply here', reply_to: parentId }); expect(reply.status).toBe(201); expect(reply.body.message.reply_to).toBe(parentId); }); it('COLLAB-017 — DELETE /collab/messages/:id removes own message', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const msg = await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(user.id)) .send({ text: 'Delete me' }); const msgId = msg.body.message.id; const del = await request(app) .delete(`/api/trips/${trip.id}/collab/messages/${msgId}`) .set('Cookie', authCookie(user.id)); expect(del.status).toBe(200); expect(del.body.success).toBe(true); }); it("COLLAB-017 — cannot delete another user's message", async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); const msg = await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(owner.id)) .send({ text: 'Owner message' }); const msgId = msg.body.message.id; const del = await request(app) .delete(`/api/trips/${trip.id}/collab/messages/${msgId}`) .set('Cookie', authCookie(member.id)); expect(del.status).toBe(403); }); }); // ───────────────────────────────────────────────────────────────────────────── // Reactions // ───────────────────────────────────────────────────────────────────────────── describe('Message reactions', () => { it('COLLAB-018 — POST /collab/messages/:id/react adds a reaction', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const msg = await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(user.id)) .send({ text: 'React to me' }); const msgId = msg.body.message.id; const res = await request(app) .post(`/api/trips/${trip.id}/collab/messages/${msgId}/react`) .set('Cookie', authCookie(user.id)) .send({ emoji: '👍' }); expect(res.status).toBe(200); expect(res.body.reactions).toBeDefined(); }); it('COLLAB-018 — POST react without emoji returns 400', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const msg = await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(user.id)) .send({ text: 'Test' }); const msgId = msg.body.message.id; const res = await request(app) .post(`/api/trips/${trip.id}/collab/messages/${msgId}/react`) .set('Cookie', authCookie(user.id)) .send({}); expect(res.status).toBe(400); }); }); // ───────────────────────────────────────────────────────────────────────────── // Long text validation // ───────────────────────────────────────────────────────────────────────────── describe('Collab validation', () => { it('COLLAB-018 — message text exceeding 5000 chars is rejected', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(user.id)) .send({ text: 'A'.repeat(5001) }); expect(res.status).toBe(400); }); it('COLLAB-008 — poll with fewer than 2 options returns 400', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .post(`/api/trips/${trip.id}/collab/polls`) .set('Cookie', authCookie(user.id)) .send({ question: 'Only one option?', options: ['Option A'] }); expect(res.status).toBe(400); // The Zod pipe's standard envelope replaced the bespoke 'At least 2 options // are required' string when the collab bodies adopted DTOs. expect(res.body.error).toMatch(/options/i); }); }); describe('Link preview', () => { it('COLLAB-025 — GET /collab/link-preview as a non-member returns 404 (trip-access check added post-migration)', async () => { const { user: owner } = createUser(testDb); const { user: outsider } = createUser(testDb); const trip = createTrip(testDb, owner.id); const res = await request(app) .get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`) .set('Cookie', authCookie(outsider.id)); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); }); it('COLLAB-025 — GET /collab/link-preview without url returns 400', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app).get(`/api/trips/${trip.id}/collab/link-preview`).set('Cookie', authCookie(user.id)); expect(res.status).toBe(400); expect(res.body.error).toMatch(/url/i); }); it('COLLAB-025 — GET /collab/link-preview returns preview for valid URL', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); linkPreviewSpy.mockResolvedValueOnce({ title: 'Example Domain', description: 'A test page', image: null, url: 'https://example.com', }); const res = await request(app) .get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.title).toBe('Example Domain'); }); it('COLLAB-026 — GET /collab/link-preview returns 400 when fetchLinkPreview returns error', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); linkPreviewSpy.mockResolvedValueOnce({ title: null, description: null, image: null, url: 'http://127.0.0.1', error: 'Requests to loopback and link-local addresses are not allowed', } as any); const res = await request(app) .get(`/api/trips/${trip.id}/collab/link-preview?url=http://127.0.0.1`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(400); expect(res.body.error).toBeDefined(); }); // Every case above mocks linkPreview out, so the SSRF guard, the redirect // refusal and the pinned dispatcher are unproven over HTTP: swapping // checkSsrf(url, true) for checkSsrf(url) would leave this file green. These // two put the real implementation back for one call each. No traffic leaves // the machine — the guard refuses both targets before any fetch. it.each([ ['loopback', 'http://127.0.0.1:9/'], // The unspecified address routes to loopback when connected to, and used to // pass the guard: it is not '::1', and it does not start with '0.'. ['the unspecified address', 'http://[::]:9/'], ])('COLLAB-032 — GET /collab/link-preview refuses %s (%s) through the real guard', async (_label, url) => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); linkPreviewSpy.mockImplementationOnce(realLinkPreview); const res = await request(app) .get(`/api/trips/${trip.id}/collab/link-preview?url=${encodeURIComponent(url)}`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(400); // One constant reason. The guard knows three, and telling them apart would // map out the internal DNS of the server one guessed hostname at a time. expect(res.body).toEqual({ error: 'URL not allowed' }); }); it('COLLAB-027 — GET /collab/link-preview catches thrown errors and returns fallback', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); linkPreviewSpy.mockRejectedValueOnce(new Error('Unexpected error')); const res = await request(app) .get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.title).toBeNull(); }); }); describe('Message reactions toggle', () => { it('COLLAB-028 — POST /collab/messages/:msgId/react adds a reaction', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const msgRes = await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(user.id)) .send({ text: 'Hello!' }); expect(msgRes.status).toBe(201); const messageId = msgRes.body.message.id; const res = await request(app) .post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`) .set('Cookie', authCookie(user.id)) .send({ emoji: '👍' }); expect(res.status).toBe(200); expect(res.body.reactions).toBeDefined(); const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍'); expect(thumbsUp).toBeDefined(); expect(thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id)).toBe(true); }); it('COLLAB-029 — POST /collab/messages/:msgId/react on same emoji removes it (toggle)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const msgRes = await request(app) .post(`/api/trips/${trip.id}/collab/messages`) .set('Cookie', authCookie(user.id)) .send({ text: 'Toggle me!' }); expect(msgRes.status).toBe(201); const messageId = msgRes.body.message.id; // First call — adds the reaction await request(app) .post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`) .set('Cookie', authCookie(user.id)) .send({ emoji: '👍' }); // Second call with same emoji — should toggle it off const res = await request(app) .post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`) .set('Cookie', authCookie(user.id)) .send({ emoji: '👍' }); expect(res.status).toBe(200); expect(res.body.reactions).toBeDefined(); const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍'); // After toggling off, either the entry is absent or the user is no longer in it const userStillReacted = thumbsUp && thumbsUp.users && thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id); expect(userStillReacted).toBeFalsy(); }); });