Files
TREK/server/tests/integration/trip-invite-removal.test.ts
T
Maurice 658536c217 fix(trips): a member another member removed cannot rejoin through the invite link
Removing a member deleted their membership row only, and the invite link
checked nothing but its own token, so whoever kept the link walked straight
back in (GHSA-wr4c-964p-39jg).

trip_member_removals now notes who another member took off a trip. The invite
link refuses them on preview and on accept with a 403 that says so, old link
or regenerated, and the join page shows that instead of the join button.
Adding them back by name, or through a trip-bound invite or a plugin acting
for a member manager, lifts the note. Leaving a trip yourself leaves none, so
the link keeps working for whoever left and for everybody else.
2026-10-24 08:29:51 +02:00

123 lines
5.1 KiB
TypeScript

import { buildApp } from '../../src/bootstrap';
import { authCookie } from '../helpers/auth';
import type { FactoryOrm } from '../helpers/factories/context';
import { makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
/**
* A member another member took off a trip cannot walk back in through the
* trip's invite link (GHSA-wr4c-964p-39jg, TIR-*). Through the real routes:
* the link, the roster's remove and add, and the join that reads both.
*/
let app: INestApplication;
let orm: FactoryOrm;
beforeAll(async () => {
app = await buildApp();
await app.init();
orm = app.get(MikroORM);
});
afterAll(async () => {
await app?.close();
});
const http = () => request(app.getHttpServer());
let scenes = 0;
/** A trip with an invite link, and Ben, who joined through it. */
async function scene() {
const n = ++scenes;
const { user: owner } = await makeUser(orm, { username: `tir-owner-${n}` });
const { user: ben } = await makeUser(orm, { username: `tir-ben-${n}` });
const trip = await makeTrip(orm, owner.id);
const created = await http().post(`/api/trips/${trip.id}/invite-link`).set('Cookie', authCookie(owner.id)).send({});
expect(created.status).toBeLessThan(300);
const token = created.body.token as string;
const joined = await http().post(`/api/trip-invites/${token}/accept`).set('Cookie', authCookie(ben.id));
expect(joined.body).toEqual({ trip_id: trip.id, joined: true });
return { owner, ben, trip, token };
}
const accept = (token: string, userId: number) =>
http().post(`/api/trip-invites/${token}/accept`).set('Cookie', authCookie(userId));
const preview = (token: string, userId: number) =>
http().get(`/api/trip-invites/${token}`).set('Cookie', authCookie(userId));
const remove = (tripId: number, userId: number, byUserId: number) =>
http().delete(`/api/trips/${tripId}/members/${userId}`).set('Cookie', authCookie(byUserId));
const canOpen = async (tripId: number, userId: number) =>
(await http().get(`/api/trips/${tripId}`).set('Cookie', authCookie(userId))).status === 200;
describe('a removed member and the invite link', () => {
it('TIR-001: removed by the owner, the old link refuses them and says why, and they stay out', async () => {
const s = await scene();
expect((await remove(s.trip.id, s.ben.id, s.owner.id)).status).toBe(200);
const seen = await preview(s.token, s.ben.id);
expect(seen.status).toBe(403);
expect(seen.body).toEqual({
error: 'You were removed from this trip. Ask its owner to add you again.',
removed: true,
});
const rejoin = await accept(s.token, s.ben.id);
expect(rejoin.status).toBe(403);
expect(rejoin.body.removed).toBe(true);
expect(await canOpen(s.trip.id, s.ben.id)).toBe(false);
});
it('TIR-002: the link still works for everybody else', async () => {
const s = await scene();
await remove(s.trip.id, s.ben.id, s.owner.id);
const { user: cara } = await makeUser(orm, { username: `tir-cara-${scenes}` });
expect((await preview(s.token, cara.id)).status).toBe(200);
expect((await accept(s.token, cara.id)).body).toEqual({ trip_id: s.trip.id, joined: true });
});
it('TIR-003: whoever left on their own may come back through the link', async () => {
const s = await scene();
expect((await remove(s.trip.id, s.ben.id, s.ben.id)).status).toBe(200);
expect(await canOpen(s.trip.id, s.ben.id)).toBe(false);
expect((await accept(s.token, s.ben.id)).body).toEqual({ trip_id: s.trip.id, joined: true });
expect(await canOpen(s.trip.id, s.ben.id)).toBe(true);
});
it('TIR-004: added back by name, then leaving on their own, they may use the link again', async () => {
const s = await scene();
await remove(s.trip.id, s.ben.id, s.owner.id);
const added = await http()
.post(`/api/trips/${s.trip.id}/members`)
.set('Cookie', authCookie(s.owner.id))
.send({ identifier: s.ben.username });
expect(added.status).toBe(201);
expect((await preview(s.token, s.ben.id)).status).toBe(200);
await remove(s.trip.id, s.ben.id, s.ben.id);
expect((await accept(s.token, s.ben.id)).body).toEqual({ trip_id: s.trip.id, joined: true });
});
it('TIR-005: removing somebody who was never a member leaves no note against them', async () => {
const s = await scene();
const { user: dana } = await makeUser(orm, { username: `tir-dana-${scenes}` });
await remove(s.trip.id, dana.id, s.owner.id);
expect((await accept(s.token, dana.id)).body).toEqual({ trip_id: s.trip.id, joined: true });
});
it('TIR-006: a regenerated link refuses the removed member too', async () => {
const s = await scene();
await remove(s.trip.id, s.ben.id, s.owner.id);
const rotated = await http()
.post(`/api/trips/${s.trip.id}/invite-link`)
.set('Cookie', authCookie(s.owner.id))
.send({});
expect((await accept(rotated.body.token as string, s.ben.id)).status).toBe(403);
});
});