mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
521e52c853
The preview endpoint takes a URL from whoever is typing in a trip chat and fetches it from the server. Trip access was added to it earlier; this is the rest of what that route needed. The SSRF guard had a hole under it. `isAlwaysBlocked` knew `::1` and `0.0.0.0` but not the IPv6 unspecified address: `::` starts with a colon, so the `0.` check never saw it, and connecting to it lands on loopback. `http://[::]:9200/` therefore passed the guard and came back with the target's title and og: tags scraped out of the body — an exfiltrating read of anything listening locally, from any account. `[::0]`, `[0:0:0:0:0:0:0:0]` and `[::ffff:0:0]` are the same address in other spellings and behaved the same way. The two `::ffff:`-prefix regexes had the same shape of gap: they matched the dotted spelling of loopback and link-local, so `::ffff:7f00:1` walked past them. Both checks now decide on the expanded hextets, which covers every spelling at once, and fe80::/10 is read as ten bits rather than as the four characters 'fe80'. This is in ssrfGuard, so every caller gets it, not just collab. The scrape could be turned into a denial of service. Each og: regex had two unbounded `[^>]*` runs separated by a literal, so a document with no `>` in it made the engine rescan the remainder from every `<meta` it passed — quadratic, and the byte cap admits half a megabyte. Measured on this build: 240KB of '<meta ' took 58 seconds, and Node has one thread, so that is the entire server, health check included, on one request. Bounding the runs at 512 characters makes it linear (540KB now takes 1.4s); no real attribute list comes close to that. The five-second timeout only covered the headers — `clearTimeout` ran before the body was read — so a server that answered fast and then dripped one byte every few seconds held the handler, the socket and the per-request undici agent open indefinitely. The byte cap counts bytes and at that rate would never reach one. `AbortSignal.timeout` covers both halves, the agent is closed in a `finally`, and the three bail-outs that used to return without touching the body now discard it, which is what cappedFetch's helper is there for. The rest is smaller. Nothing metered the fetches, so an account with a trip of its own could drive arbitrary outbound requests in a loop; there is a budget of sixty new fetches a minute per user now, charged per outbound fetch rather than per request. Previews are cached for ten minutes and simultaneous askers for one URL share a single fetch, so the client's habit of requesting a preview per rendered message — up to a hundred at once, with no debounce — costs one fetch per new link rather than one per render. Refusals answer with one constant reason instead of the guard's own: it distinguishes "could not resolve" from "private address", which together map out the server's internal DNS for anyone willing to guess hostnames. A body whose declared type is not markup is dropped rather than combed for og: tags, and an og:image that is not an http(s) URL is not passed to the client, which puts it straight into an <img src>. Deliberately no `@RequirePermission` on the route: the client asks for a preview while rendering a message or a note, never while composing one, so it belongs with the GET siblings. Requiring collab_edit would blank every preview for members of a trip whose owner narrowed that right while leaving them able to read the chat, and it would not stop the abuse it looks like it addresses — the permission check passes for a trip's owner at every level, and anyone can create a trip.
909 lines
35 KiB
TypeScript
909 lines
35 KiB
TypeScript
/**
|
||
* 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 { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
import request from 'supertest';
|
||
import type { Application } from 'express';
|
||
import type { INestApplication } from '@nestjs/common';
|
||
import path from 'path';
|
||
import fs from 'fs';
|
||
|
||
const { testDb, dbMock } = vi.hoisted(() => {
|
||
const Database = require('better-sqlite3');
|
||
const db = new Database(':memory:');
|
||
db.exec('PRAGMA journal_mode = WAL');
|
||
db.exec('PRAGMA foreign_keys = ON');
|
||
db.exec('PRAGMA busy_timeout = 5000');
|
||
const mock = {
|
||
db,
|
||
closeDb: () => {},
|
||
reinitialize: () => {},
|
||
getPlaceWithTags: (placeId: number) => {
|
||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||
if (!place) return null;
|
||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||
},
|
||
canAccessTrip: (tripId: any, userId: number) =>
|
||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||
isOwner: (tripId: any, userId: number) =>
|
||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||
};
|
||
return { testDb: db, dbMock: mock };
|
||
});
|
||
|
||
vi.mock('../../src/db/database', () => dbMock);
|
||
vi.mock('../../src/config', () => ({
|
||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||
updateJwtSecret: () => {},
|
||
SESSION_DURATION: '24h',
|
||
SESSION_DURATION_MS: 86400000,
|
||
SESSION_DURATION_SECONDS: 86400,
|
||
DEFAULT_LANGUAGE: 'en',
|
||
}));
|
||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||
|
||
import { buildApp } from '../../src/bootstrap';
|
||
import { createTables } from '../../src/db/schema';
|
||
import { runMigrations } from '../../src/db/migrations';
|
||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||
import { authCookie, generateToken } from '../helpers/auth';
|
||
import { CollabService } from '../../src/nest/collab/collab.service';
|
||
|
||
// 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 () => {
|
||
createTables(testDb);
|
||
runMigrations(testDb);
|
||
nestApp = await buildApp();
|
||
app = nestApp.getHttpAdapter().getInstance();
|
||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||
});
|
||
|
||
beforeEach(() => {
|
||
resetTestDb(testDb);
|
||
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, '<svg></svg>');
|
||
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();
|
||
});
|
||
});
|