mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
b3a085df3f
The process-state ports were meant to let a store shared between processes replace the in-memory one, but they could not. The readers outside the container (the broadcast functions, the backup restore's permissions-cache flush, the pending-code sweep, hand-built consumers) went straight to the module-level in-memory instances, so an overridden provider was used by the gateway and the services while the broadcasts and the restore flush kept using the old store. And every port was synchronous, while a store in the database or on the network is async, so plugging one in would have changed every signature anyway. RateLimitStore, PendingCodeStore, OidcFlowStore and PermissionsCacheStore now return promises, and RateLimitService, OauthService, OidcService, PermissionsService and their callers await them. The in-memory stores still decide synchronously, so who gets a 429, a code or a cached level is unchanged. RoomRegistry stays synchronous: membership holds live sockets and broadcast() is a synchronous call every domain makes, so several processes need a fan-out in front of broadcast(), not a different place for the sets. The process-wide ports get a ProcessStoreSlot. PermissionsModule, OauthModule and RealtimeGatewayModule install the store the container resolved and release it on shutdown, and the free functions, the sweep and hand-built consumers read the slot, so overriding the provider changes every reader. Tests provide fake stores and check that the broadcasts, the restore flush and the sweep use them. Two callers needed more than an await. The transit MCP tools counted in a RateLimitService of their own that no provider could reach; they now take the one RateLimitModule provides (their mcp_transit_* buckets never overlap the REST ones). The link preview claimed its in-flight slot only after the budget check, which now yields, so simultaneous askers would each have been charged; it now claims the slot first, and an asker that joined someone out of budget asks again on its own budget. The unused putPendingCode/takePendingCode helpers are gone.
885 lines
34 KiB
TypeScript
885 lines
34 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';
|
||
|
||
vi.mock('../../src/db/database', async () => {
|
||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||
return buildDbMock(createSnapshotTestDb());
|
||
});
|
||
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 { db as testDb } from '../../src/db/database';
|
||
import { buildApp } from '../../src/bootstrap';
|
||
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 () => {
|
||
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, '<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();
|
||
});
|
||
});
|