Files
TREK/server/tests/e2e/trip-invite.e2e.test.ts
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

223 lines
9.6 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 { db } from '../../src/db/database';
import { TripInviteTokens } from '../../src/db/entities/TripInviteTokens.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { TripInviteModule } from '../../src/nest/trip-invite/trip-invite.module';
import { TripMembershipService } from '../../src/nest/trip-membership/trip-membership.service';
import { countRows, deleteRows, findRows, insertRow } from '../helpers/factories/rows';
import { makeUser } from '../helpers/factories/users';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
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() };
});
// 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(),
}));
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);
});
});