mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
583ba17606
The e2e suites for assignments, places, bookings, trips, the public API, doc sync, addons, transit, to-dos, admin, invite links, school holidays, categories, feeds and plugin search seeded and inspected their database with raw better-sqlite3 statements. They now go through the ORM factories and readers, the way the repositories write and read. Six of them (public API, transit, to-dos, admin, invite links, categories) still built a handful of hand-rolled tables. They move to the migrated snapshot, since an entity reads and writes every mapped column; where the real schema keeps usernames unique, the seeded users get distinct names and keep the ones the assertions read back. The dead canAccessTrip stubs left in two database mocks are dropped: nothing in src imports it from db/database any more.
211 lines
9.5 KiB
TypeScript
211 lines
9.5 KiB
TypeScript
/**
|
|
* Trip invite-link e2e — exercises /api/trips/:tripId/invite-link (manage) and
|
|
* /api/trip-invites/:token (preview + accept) through the real JwtAuthGuard
|
|
* against a migrated temp SQLite db (createSnapshotTestDb()), seeded and read
|
|
* through the factories in tests/helpers/factories. TripInviteService is
|
|
* DI-native and runs its real SQL against the temp db; only the permission check, membership join and
|
|
* audit log are mocked. Focuses on auth (401), trip-access 404, the
|
|
* share_manage 403, the login-required join, and invalid-token 404s (#1143).
|
|
*/
|
|
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 { Test } from '@nestjs/testing';
|
|
import { sessionCookie } from './harness';
|
|
|
|
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
|
return { ...buildDbMock(createSnapshotTestDb()), canAccessTrip, getPlaceWithTags: vi.fn() };
|
|
});
|
|
|
|
import { db } from '../../src/db/database';
|
|
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;
|
|
|
|
// The join itself is stubbed out of the container (TRIP-JOIN-* cover what it
|
|
// writes); these cases assert the invite route's own decision to call it.
|
|
const joinTripAsMember = vi.fn();
|
|
|
|
// The audit domain is DI-native now: writeAudit runs for real against the temp
|
|
// db's audit_log table; only the file logger is silenced.
|
|
vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() }));
|
|
|
|
import { TripInviteModule } from '../../src/nest/trip-invite/trip-invite.module';
|
|
import { TripMembershipService } from '../../src/nest/trip-membership/trip-membership.service';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
|
import { makeUser } from '../helpers/factories/users';
|
|
import { countRows, deleteRows, findRows, insertRow } from '../helpers/factories/rows';
|
|
import { TripInviteTokens } from '../../src/db/entities/TripInviteTokens.entity';
|
|
import { Trips } from '../../src/db/entities/Trips.entity';
|
|
|
|
let orm: TestOrm;
|
|
|
|
describe('Trip invite-link 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), TripInviteModule] })
|
|
.overrideProvider(TripMembershipService).useValue({ joinTripAsMember })
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
async function seedTrip(id: number, title: string, ownerId = 1) {
|
|
await insertRow(orm, Trips, { id, title, user: ownerId });
|
|
}
|
|
async function seedToken(tripId: number, token: string, expiresAt: string | null = null) {
|
|
await insertRow(orm, TripInviteTokens, { trip: tripId, token, createdByRef: 1, expires_at: expiresAt });
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
orm = await createTestOrm(db);
|
|
await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test' });
|
|
await makeUser(orm, { id: 2, username: 'e2e-user-2', email: 'e2e-2@example.test' });
|
|
app = await build();
|
|
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await deleteRows(orm, TripInviteTokens);
|
|
await deleteRows(orm, Trips);
|
|
// 0b review L2 / security review F-B7: dead mock scaffolding — see
|
|
// budget.e2e.test.ts's identical comment.
|
|
checkPermission.mockReturnValue(true);
|
|
joinTripAsMember.mockReset();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await orm.close();
|
|
});
|
|
|
|
// ── manage ──
|
|
it('401 without a session cookie', async () => {
|
|
expect((await request(server).get('/api/trips/5/invite-link')).status).toBe(401);
|
|
});
|
|
|
|
it('GET returns the current link for a trip member', async () => {
|
|
await seedTrip(5, 'Lisbon');
|
|
await seedToken(5, 'abc');
|
|
const res = await request(server).get('/api/trips/5/invite-link').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.token).toBe('abc');
|
|
expect(res.body.expires_at).toBeNull();
|
|
});
|
|
|
|
it('GET returns { token: null } when no link exists', async () => {
|
|
await seedTrip(5, 'Lisbon');
|
|
const res = await request(server).get('/api/trips/5/invite-link').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ token: null });
|
|
});
|
|
|
|
it('POST creates/rotates the link', async () => {
|
|
await seedTrip(5, 'Lisbon');
|
|
await seedToken(5, 'old-token');
|
|
const res = await request(server).post('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)).send({});
|
|
expect([200, 201]).toContain(res.status);
|
|
expect(res.body.token).toMatch(/^[A-Za-z0-9_-]{20,}$/);
|
|
expect(res.body.token).not.toBe('old-token');
|
|
const rows = await findRows(orm, TripInviteTokens, { trip: 5 });
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].token).toBe(res.body.token);
|
|
});
|
|
|
|
it('POST with expires_in_days bounds the link life', async () => {
|
|
await seedTrip(5, 'Lisbon');
|
|
const res = await request(server).post('/api/trips/5/invite-link').set('Cookie', sessionCookie(1))
|
|
.send({ expires_in_days: 7 });
|
|
expect([200, 201]).toContain(res.status);
|
|
const expires = new Date(res.body.expires_at).getTime();
|
|
expect(expires).toBeGreaterThan(Date.now() + 6 * 86400000);
|
|
expect(expires).toBeLessThan(Date.now() + 8 * 86400000);
|
|
});
|
|
|
|
it('POST 400 for a non-numeric expires_in_days string', async () => {
|
|
await seedTrip(5, 'Lisbon');
|
|
const res = await request(server).post('/api/trips/5/invite-link').set('Cookie', sessionCookie(1))
|
|
.send({ expires_in_days: '7abc' });
|
|
expect(res.status).toBe(400);
|
|
expect(await countRows(orm, TripInviteTokens)).toBe(0);
|
|
});
|
|
|
|
it('403 to create without share_manage', async () => {
|
|
await seedTrip(5, 'Lisbon');
|
|
checkPermission.mockReturnValue(false);
|
|
const res = await request(server).post('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)).send({});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('403 to READ the link without share_manage (token grants membership)', async () => {
|
|
await seedTrip(5, 'Lisbon');
|
|
checkPermission.mockReturnValue(false);
|
|
const res = await request(server).get('/api/trips/5/invite-link').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('404 when the trip is not accessible', async () => {
|
|
// Plan 3c Task 0b: TripAccessGuard reads TripsRepository.findAccessible
|
|
// directly now, a real query — no trip 5 row exists (nothing in this
|
|
// test seeded one, and `beforeEach` clears the table), so the guard's
|
|
// real 404 fires without a `canAccessTrip` mock to fake it.
|
|
const res = await request(server).get('/api/trips/5/invite-link').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
// ── preview + accept (login required) ──
|
|
it('401 to preview an invite without a session (login required, never registration)', async () => {
|
|
expect((await request(server).get('/api/trip-invites/tok')).status).toBe(401);
|
|
});
|
|
|
|
it('preview resolves the trip title for an authed user', async () => {
|
|
await seedTrip(9, 'Rome 2026');
|
|
await seedToken(9, 'tok');
|
|
const res = await request(server).get('/api/trip-invites/tok').set('Cookie', sessionCookie(2));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ trip_id: 9, title: 'Rome 2026' });
|
|
});
|
|
|
|
it('preview 404 for an invalid/expired token', async () => {
|
|
await seedTrip(9, 'Rome 2026');
|
|
await seedToken(9, 'tok', new Date(Date.now() - 3600_000).toISOString());
|
|
expect((await request(server).get('/api/trip-invites/bad').set('Cookie', sessionCookie(2))).status).toBe(404);
|
|
expect((await request(server).get('/api/trip-invites/tok').set('Cookie', sessionCookie(2))).status).toBe(404);
|
|
});
|
|
|
|
it('accept joins the current user and returns the trip id', async () => {
|
|
await seedTrip(9, 'Rome 2026');
|
|
await seedToken(9, 'tok');
|
|
joinTripAsMember.mockReturnValueOnce({ joined: true, tripId: 9 });
|
|
const res = await request(server).post('/api/trip-invites/tok/accept').set('Cookie', sessionCookie(2));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ trip_id: 9, joined: true });
|
|
expect(joinTripAsMember).toHaveBeenCalledWith(9, 2, null);
|
|
});
|
|
|
|
it('accept 404 for an invalid/expired token (no join attempted)', async () => {
|
|
const res = await request(server).post('/api/trip-invites/bad/accept').set('Cookie', sessionCookie(2));
|
|
expect(res.status).toBe(404);
|
|
expect(joinTripAsMember).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('401 to accept without a session', async () => {
|
|
expect((await request(server).post('/api/trip-invites/tok/accept')).status).toBe(401);
|
|
});
|
|
});
|