mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +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.
311 lines
17 KiB
TypeScript
311 lines
17 KiB
TypeScript
/**
|
|
* Collab module e2e — exercises the migrated /api/trips/:tripId/collab endpoints
|
|
* through the real JwtAuthGuard against a temp SQLite db, running CollabService's
|
|
* real SQL (DI-injected, no service mock). canAccessTrip, the permission check,
|
|
* the WebSocket broadcast and the chat/note notification are mocked; this focuses
|
|
* on auth, trip-access 404, permission 403, the create-201 status codes, the
|
|
* vote/react 200 overrides and the persisted rows.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
|
import request from 'supertest';
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
|
import { Test } from '@nestjs/testing';
|
|
import { seedUser, sessionCookie } from './harness';
|
|
|
|
const { db } = vi.hoisted(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const Database = require('better-sqlite3');
|
|
const tmp = new Database(':memory:');
|
|
tmp.exec('PRAGMA journal_mode = WAL');
|
|
// The note/message/poll formatters join users for username + avatar.
|
|
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
|
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
|
|
avatar TEXT);`);
|
|
// The note/message notifications read the trip title fire-and-forget; the table
|
|
// must exist so that query doesn't throw after the test has torn down.
|
|
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, user_id INTEGER, currency TEXT);');
|
|
// TripAccessGuard now reads TripsRepository.findAccessible directly
|
|
// (Plan 3c Task 0b), a real join against trip_members.
|
|
tmp.exec('CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);');
|
|
// CollabService's real SQL (DI-injected, no mock) — the collab tables as the
|
|
// real schema + migrations shape them (website on notes, deleted on messages,
|
|
// note_id on trip_files).
|
|
tmp.exec(`CREATE TABLE collab_notes (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL, category TEXT DEFAULT 'General', title TEXT NOT NULL, content TEXT,
|
|
color TEXT DEFAULT '#6366f1', pinned INTEGER DEFAULT 0, website TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
|
tmp.exec(`CREATE TABLE collab_links (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, pinned INTEGER DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
|
tmp.exec(`CREATE TABLE collab_polls (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL, question TEXT NOT NULL, options TEXT NOT NULL, multiple INTEGER DEFAULT 0,
|
|
closed INTEGER DEFAULT 0, deadline TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
|
tmp.exec(`CREATE TABLE collab_poll_votes (id INTEGER PRIMARY KEY AUTOINCREMENT, poll_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL, option_index INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(poll_id, user_id, option_index));`);
|
|
tmp.exec(`CREATE TABLE collab_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL, text TEXT NOT NULL, reply_to INTEGER, deleted INTEGER DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
|
tmp.exec(`CREATE TABLE collab_message_reactions (id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
message_id INTEGER NOT NULL, user_id INTEGER NOT NULL, emoji TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
|
tmp.exec(`CREATE TABLE trip_files (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
|
|
note_id INTEGER, message_id INTEGER, filename TEXT NOT NULL, original_name TEXT NOT NULL,
|
|
file_size INTEGER, mime_type TEXT, uploaded_by INTEGER, deleted_at DATETIME,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
|
// StorageRegistryService (behind StorageModule, now in this module chain) reads
|
|
// this at onModuleInit.
|
|
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
|
return { db: tmp };
|
|
});
|
|
|
|
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
|
vi.mock('../../src/db/database', () => ({
|
|
db, canAccessTrip, getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
|
}));
|
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
|
|
|
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
|
|
|
// Since the permissions DI migration, the check is a spy on the container's
|
|
// PermissionsService singleton (created in beforeAll, after build()).
|
|
let checkPermission: MockInstance;
|
|
|
|
import { CollabModule } from '../../src/nest/collab/collab.module';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { RateLimitService } from '../../src/nest/common/rate-limit.service';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
|
|
|
describe('Collab e2e (real auth guard + temp SQLite)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), RealtimeModule, CollabModule] }).compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
// AppModule registers this as APP_PIPE; the harness only pulls CollabModule,
|
|
// so without it the write routes would run unvalidated here and a broken
|
|
// contract would still look green.
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
seedUser(db as never, { id: 1 });
|
|
app = await build();
|
|
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Plan 3c Task 0b: TripAccessGuard reads TripsRepository.findAccessible
|
|
// directly now, a real query — `canAccessTrip.mockReturnValue(...)` no
|
|
// longer intercepts it, so trip 5's real row is (re-)seeded every test
|
|
// instead, owned by user 1, matching what the mock used to fake.
|
|
db.prepare('DELETE FROM trips WHERE id = 5').run();
|
|
db.prepare("INSERT INTO trips (id, title, user_id) VALUES (5, 'Trip', 1)").run();
|
|
checkPermission.mockReturnValue(true);
|
|
db.prepare('DELETE FROM collab_message_reactions').run();
|
|
db.prepare('DELETE FROM collab_poll_votes').run();
|
|
db.prepare('DELETE FROM collab_messages').run();
|
|
db.prepare('DELETE FROM collab_polls').run();
|
|
db.prepare('DELETE FROM trip_files').run();
|
|
db.prepare('DELETE FROM collab_notes').run();
|
|
db.prepare('DELETE FROM collab_links').run();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
it('401 without a session cookie', async () => {
|
|
expect((await request(server).get('/api/trips/5/collab/notes')).status).toBe(401);
|
|
});
|
|
|
|
it('200 list notes for an accessible trip', async () => {
|
|
db.prepare("INSERT INTO collab_notes (id, trip_id, user_id, title) VALUES (1, 5, 1, 'N')").run();
|
|
const res = await request(server).get('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.notes).toHaveLength(1);
|
|
expect(res.body.notes[0]).toMatchObject({ id: 1, title: 'N', category: 'General', attachments: [] });
|
|
});
|
|
|
|
it('404 when the trip is not accessible', async () => {
|
|
db.prepare('DELETE FROM trips WHERE id = 5').run();
|
|
const res = await request(server).get('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Trip not found' });
|
|
});
|
|
|
|
it('201 on note create with permission, row persisted', async () => {
|
|
const res = await request(server).post('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1)).send({ title: 'N' });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.note).toMatchObject({ title: 'N', category: 'General', color: '#6366f1', pinned: 0 });
|
|
const row = db.prepare('SELECT * FROM collab_notes WHERE trip_id = 5').get() as { title: string };
|
|
expect(row.title).toBe('N');
|
|
});
|
|
|
|
it('403 on note create without permission', async () => {
|
|
checkPermission.mockReturnValue(false);
|
|
const res = await request(server).post('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1)).send({ title: 'N' });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({ error: 'No permission' });
|
|
});
|
|
|
|
it('200 on poll vote (not 201), vote persisted', async () => {
|
|
db.prepare('INSERT INTO collab_polls (id, trip_id, user_id, question, options) VALUES (7, 5, 1, ?, ?)')
|
|
.run('Q?', JSON.stringify(['A', 'B']));
|
|
const res = await request(server).post('/api/trips/5/collab/polls/7/vote').set('Cookie', sessionCookie(1)).send({ option_index: 0 });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.poll).toMatchObject({ id: 7, is_closed: false });
|
|
expect(res.body.poll.options[0].voters).toHaveLength(1);
|
|
const vote = db.prepare('SELECT * FROM collab_poll_votes WHERE poll_id = 7').get() as { option_index: number };
|
|
expect(vote.option_index).toBe(0);
|
|
});
|
|
|
|
it('201 on message create, row persisted', async () => {
|
|
const res = await request(server).post('/api/trips/5/collab/messages').set('Cookie', sessionCookie(1)).send({ text: 'hi' });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.message).toMatchObject({ text: 'hi', trip_id: 5, user_id: 1 });
|
|
const row = db.prepare('SELECT * FROM collab_messages WHERE trip_id = 5').get() as { text: string };
|
|
expect(row.text).toBe('hi');
|
|
});
|
|
|
|
it('200 on react (not 201), reaction persisted and toggled', async () => {
|
|
db.prepare("INSERT INTO collab_messages (id, trip_id, user_id, text) VALUES (3, 5, 1, 'react me')").run();
|
|
const res = await request(server).post('/api/trips/5/collab/messages/3/react').set('Cookie', sessionCookie(1)).send({ emoji: '👍' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.reactions).toHaveLength(1);
|
|
expect(res.body.reactions[0]).toMatchObject({ emoji: '👍', count: 1 });
|
|
expect(db.prepare('SELECT COUNT(*) as c FROM collab_message_reactions WHERE message_id = 3').get()).toEqual({ c: 1 });
|
|
});
|
|
|
|
// The advisory this route was reported under: it answered anyone with a session,
|
|
// for any trip id, and drove an outbound fetch from that.
|
|
it('404 on link-preview for a trip the caller cannot reach', async () => {
|
|
db.prepare('DELETE FROM trips WHERE id = 5').run();
|
|
const res = await request(server)
|
|
.get('/api/trips/5/collab/link-preview?url=https://example.com/')
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Trip not found' });
|
|
});
|
|
|
|
it('401 on link-preview without a session cookie', async () => {
|
|
const res = await request(server).get('/api/trips/5/collab/link-preview?url=https://example.com/');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
// A read-only member still gets previews: the route is requested while rendering
|
|
// the chat, so gating it on the write permission would blank the chat for them.
|
|
it('200 on link-preview for a member without collab_edit', async () => {
|
|
checkPermission.mockReturnValue(false);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true, headers: { get: () => null }, text: async () => '<title>Lesbar</title>',
|
|
}));
|
|
const res = await request(server)
|
|
.get('/api/trips/5/collab/link-preview?url=https://example.com/reader')
|
|
.set('Cookie', sessionCookie(1));
|
|
vi.unstubAllGlobals();
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.title).toBe('Lesbar');
|
|
});
|
|
|
|
it('429 once the caller has spent a minute of preview fetches', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true, headers: { get: () => null }, text: async () => '<title>T</title>',
|
|
}));
|
|
// Distinct URLs, because a repeat is served from the cache and costs nothing.
|
|
let last = 200;
|
|
for (let i = 0; i < 61 && last === 200; i++) {
|
|
last = (await request(server)
|
|
.get(`/api/trips/5/collab/link-preview?url=${encodeURIComponent(`https://example.com/e2e-${i}`)}`)
|
|
.set('Cookie', sessionCookie(1))).status;
|
|
}
|
|
vi.unstubAllGlobals();
|
|
expect(last).toBe(429);
|
|
// The counters live on the container singleton, so a spent budget would
|
|
// follow this user into every test declared after it.
|
|
await app.get(RateLimitService).reset('collab_link_preview');
|
|
});
|
|
|
|
it('400 on link-preview without a url', async () => {
|
|
const res = await request(server).get('/api/trips/5/collab/link-preview').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ error: 'URL is required' });
|
|
});
|
|
|
|
describe('shared links', () => {
|
|
it('401 without a session cookie', async () => {
|
|
expect((await request(server).get('/api/trips/5/collab/links')).status).toBe(401);
|
|
});
|
|
|
|
it('404 when the trip is not accessible', async () => {
|
|
db.prepare('DELETE FROM trips WHERE id = 5').run();
|
|
const res = await request(server).get('/api/trips/5/collab/links').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('201 on create, and the row is persisted with the acting user', async () => {
|
|
const res = await request(server).post('/api/trips/5/collab/links').set('Cookie', sessionCookie(1))
|
|
.send({ title: 'Ferry', url: 'https://example.com/ferry' });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.link).toMatchObject({ title: 'Ferry', url: 'https://example.com/ferry' });
|
|
const row = db.prepare('SELECT * FROM collab_links WHERE trip_id = 5').get() as { title: string; user_id: number };
|
|
expect(row).toMatchObject({ title: 'Ferry', user_id: 1 });
|
|
});
|
|
|
|
it('403 on create without collab_edit', async () => {
|
|
checkPermission.mockReturnValue(false);
|
|
const res = await request(server).post('/api/trips/5/collab/links').set('Cookie', sessionCookie(1))
|
|
.send({ title: 'Ferry', url: 'https://example.com/ferry' });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('400 when the body does not satisfy the contract', async () => {
|
|
const res = await request(server).post('/api/trips/5/collab/links').set('Cookie', sessionCookie(1)).send({ title: 'Ferry' });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('200 on list, pinned first', async () => {
|
|
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url, pinned) VALUES (1, 5, 1, 'Plain', 'https://a.test', 0)").run();
|
|
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url, pinned) VALUES (2, 5, 1, 'Pinned', 'https://b.test', 1)").run();
|
|
const res = await request(server).get('/api/trips/5/collab/links').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.links.map((l: { title: string }) => l.title)).toEqual(['Pinned', 'Plain']);
|
|
});
|
|
|
|
it('200 on update and the pin lands in the row', async () => {
|
|
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url) VALUES (1, 5, 1, 'Plain', 'https://a.test')").run();
|
|
const res = await request(server).put('/api/trips/5/collab/links/1').set('Cookie', sessionCookie(1)).send({ pinned: true });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.link).toMatchObject({ id: 1, pinned: 1 });
|
|
});
|
|
|
|
it('404 on update and delete of a link that is not there', async () => {
|
|
expect((await request(server).put('/api/trips/5/collab/links/99').set('Cookie', sessionCookie(1)).send({ pinned: true })).status).toBe(404);
|
|
expect((await request(server).delete('/api/trips/5/collab/links/99').set('Cookie', sessionCookie(1))).status).toBe(404);
|
|
});
|
|
|
|
it('200 on delete and the row is gone', async () => {
|
|
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url) VALUES (1, 5, 1, 'Plain', 'https://a.test')").run();
|
|
const res = await request(server).delete('/api/trips/5/collab/links/1').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(db.prepare('SELECT COUNT(*) c FROM collab_links').get()).toEqual({ c: 0 });
|
|
});
|
|
|
|
it('403 on delete without collab_edit', async () => {
|
|
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url) VALUES (1, 5, 1, 'Plain', 'https://a.test')").run();
|
|
checkPermission.mockReturnValue(false);
|
|
expect((await request(server).delete('/api/trips/5/collab/links/1').set('Cookie', sessionCookie(1))).status).toBe(403);
|
|
});
|
|
});
|
|
});
|