import { INestApplication } from '@nestjs/common'; import { PondMembersView } from '@dorfteich/shared'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { PondsService } from '../ponds/ponds.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** * Pond member management end to end (issue #54): add/change/remove by role, * seat quotas, personal-pond and owner rules, and read-only transparency for * non-admin members — all through HTTP so the guard and the service are proven * together. */ describe.skipIf(!hasTestDb)('pond members (e2e, issue #54)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'mitglieder brauchen keine einladung 1'; const userIds: Record = {}; const emails: Record = {}; const cookies: Record = {}; let pondId: string; let personalPondId: string; const api = () => request(app.getHttpServer()); async function makeUser(handle: string): Promise { const users = app.get(UsersService); const username = `mem-${handle}-${suffix}`; const email = `${username}@example.org`; const user = await users.createUser({ username, email, displayName: `Mem ${handle}`, password, locale: 'en', }); userIds[handle] = user.id; emails[handle] = email; await users.markEmailVerified(user.id); const res = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200); cookies[handle] = sessionCookieOf(res); } const membersOf = async (as: string): Promise => { const res = await api() .get(`/api/v1/ponds/${pondId}/members`) .set('Cookie', cookies[as]!) .expect(200); return res.body as PondMembersView; }; const usernameOf = (handle: string) => `mem-${handle}-${suffix}`; beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); for (const handle of ['owner', 'alice', 'bob', 'carol']) await makeUser(handle); const owner = await prisma.user.findUniqueOrThrow({ where: { id: userIds.owner! } }); await app.get(PondsService).ensurePersonalPond(owner); personalPondId = ( await prisma.pond.findFirstOrThrow({ where: { ownerId: userIds.owner!, type: 'PERSONAL' }, select: { id: true }, }) ).id; await prisma.quotaOverride.create({ data: { subjectType: 'USER', subjectId: userIds.owner!, quotaKey: 'additional_ponds', value: 10, }, }); const pond = await api() .post('/api/v1/ponds') .set('Cookie', cookies.owner!) .send({ name: `Members Pond ${suffix}` }) .expect(201); pondId = (pond.body as { id: string }).id; }); afterAll(async () => { const ids = Object.values(userIds); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...ids, pondId] } } }); await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.$disconnect(); await app.close(); }); it('lists the owner as the sole admin member initially', async () => { const view = await membersOf('owner'); expect(view.pondType).toBe('shared'); expect(view.canManage).toBe(true); expect(view.members).toHaveLength(1); expect(view.members[0]).toMatchObject({ userId: userIds.owner, role: 'pond_admin', isOwner: true, }); expect(view.seats).toEqual({ editor: { used: 0, limit: 5 }, reader: { used: 0, limit: 50 }, }); }); it('adds a member by username and by e-mail; both take effect immediately', async () => { await api() .post(`/api/v1/ponds/${pondId}/members`) .set('Cookie', cookies.owner!) .send({ usernameOrEmail: usernameOf('alice'), role: 'reader' }) .expect(201); await api() .post(`/api/v1/ponds/${pondId}/members`) .set('Cookie', cookies.owner!) .send({ usernameOrEmail: emails.bob, role: 'editor' }) .expect(201); const view = await membersOf('owner'); expect(view.members.map((m) => m.userId).sort()).toEqual( [userIds.owner!, userIds.alice!, userIds.bob!].sort(), ); expect(view.seats.editor).toEqual({ used: 1, limit: 5 }); // The new reader can immediately read the pond (and see the member list). await api().get(`/api/v1/ponds/${pondId}/members`).set('Cookie', cookies.alice!).expect(200); }); it('rejects unknown users and duplicate members', async () => { await api() .post(`/api/v1/ponds/${pondId}/members`) .set('Cookie', cookies.owner!) .send({ usernameOrEmail: `ghost-${suffix}`, role: 'reader' }) .expect(400) .expect((r) => expect((r.body as { code: string }).code).toBe('member_not_found')); await api() .post(`/api/v1/ponds/${pondId}/members`) .set('Cookie', cookies.owner!) .send({ usernameOrEmail: usernameOf('alice'), role: 'editor' }) .expect(409) .expect((r) => expect((r.body as { code: string }).code).toBe('member_exists')); }); it('non-admin members see the list read-only but cannot manage', async () => { const view = await membersOf('alice'); expect(view.canManage).toBe(false); expect(view.members.length).toBeGreaterThan(0); await api() .post(`/api/v1/ponds/${pondId}/members`) .set('Cookie', cookies.alice!) .send({ usernameOrEmail: usernameOf('carol'), role: 'reader' }) .expect(403); await api() .patch(`/api/v1/ponds/${pondId}/members/${userIds.bob}`) .set('Cookie', cookies.alice!) .send({ role: 'reader' }) .expect(403); }); it('enforces the editor seat quota and re-enables when it is raised', async () => { // Pin the pond to a single editor seat; bob already holds it. await prisma.quotaOverride.create({ data: { subjectType: 'POND', subjectId: pondId, quotaKey: 'editors_per_pond', value: 1, }, }); await api() .patch(`/api/v1/ponds/${pondId}/members/${userIds.alice}`) .set('Cookie', cookies.owner!) .send({ role: 'editor' }) .expect(403) .expect((r) => expect((r.body as { code: string }).code).toBe('quota_exceeded')); await prisma.quotaOverride.update({ where: { subjectType_subjectId_quotaKey: { subjectType: 'POND', subjectId: pondId, quotaKey: 'editors_per_pond', }, }, data: { value: 5 }, }); await api() .patch(`/api/v1/ponds/${pondId}/members/${userIds.alice}`) .set('Cookie', cookies.owner!) .send({ role: 'editor' }) .expect(200) .expect((r) => expect((r.body as { role: string }).role).toBe('editor')); }); it('shared ponds allow a second admin; personal ponds refuse one', async () => { await api() .patch(`/api/v1/ponds/${pondId}/members/${userIds.bob}`) .set('Cookie', cookies.owner!) .send({ role: 'pond_admin' }) .expect(200); const admins = (await membersOf('owner')).members.filter((m) => m.role === 'pond_admin'); expect(admins.map((m) => m.userId).sort()).toEqual([userIds.owner!, userIds.bob!].sort()); await api() .post(`/api/v1/ponds/${personalPondId}/members`) .set('Cookie', cookies.owner!) .send({ usernameOrEmail: usernameOf('alice'), role: 'pond_admin' }) .expect(400) .expect((r) => expect((r.body as { code: string }).code).toBe('grant_pond_admin_personal_pond'), ); }); it("refuses to manage the owner's own membership", async () => { await api() .patch(`/api/v1/ponds/${pondId}/members/${userIds.owner}`) .set('Cookie', cookies.owner!) .send({ role: 'reader' }) .expect(409) .expect((r) => expect((r.body as { code: string }).code).toBe('member_is_owner')); await api() .delete(`/api/v1/ponds/${pondId}/members/${userIds.owner}`) .set('Cookie', cookies.owner!) .expect(409); }); it('removes a member; access is gone on the next request', async () => { await api() .delete(`/api/v1/ponds/${pondId}/members/${userIds.alice}`) .set('Cookie', cookies.owner!) .expect(204); const view = await membersOf('owner'); expect(view.members.map((m) => m.userId)).not.toContain(userIds.alice); // Alice lost her reader grant → the pond (and its member list) hides itself. await api().get(`/api/v1/ponds/${pondId}/members`).set('Cookie', cookies.alice!).expect(404); }); });