All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m29s
CI / Auth e2e pack (push) Successful in 3m8s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 12s
Pond Admins manage who participates in a pond, by role, with editor/reader seat quotas — the member-facing layer over the grant model (#51/#52). - shared: `MemberView`/`PondMembersView` + add/change-role schemas (`members.ts`), a `members` i18n namespace (de+en), and member error codes. - api `members/`: a member-centric API over pond-scope user grants — `GET /ponds/:id/members` (any member, for transparency: list grouped by effective role + seat usage + `canManage`), `POST` (add by exact username or e-mail — no directory browsing), `PATCH :userId` (change role), `DELETE :userId` (remove), all Pond-Admin-gated by the guard. Editor/reader seats are enforced against `editors_per_pond`/`readers_per_pond` (#22) inside a per-pond advisory-locked transaction so counts cannot race; the owner's membership is protected, personal ponds refuse a second admin (shared grant rule), and the last Pond Admin cannot be dropped. Every change invalidates the pond permission cache and fires the access NOTIFY (#39/#53). - web `members/`: `MemberManager` in Pond Settings — list grouped by role with a search filter and seat usage, add-by-identifier form (disabled with a localized explanation when the chosen role's seats are full), per-member role change and remove; read-only for non-admins; the personal-pond rule is surfaced. There is no invitation flow (v1): adding is immediate, and the copy says so. - tests: `members.e2e.db.test.ts` (add/change/remove, seat exhaustion, personal-pond and owner rules, read-only transparency, last-admin) and a `members` browser pack (immediate second-browser access, quota disables the add action, non-admin read-only) with its own CI step. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
252 lines
8.8 KiB
TypeScript
252 lines
8.8 KiB
TypeScript
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<string, string> = {};
|
|
const emails: Record<string, string> = {};
|
|
const cookies: Record<string, string> = {};
|
|
|
|
let pondId: string;
|
|
let personalPondId: string;
|
|
|
|
const api = () => request(app.getHttpServer());
|
|
|
|
async function makeUser(handle: string): Promise<void> {
|
|
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<PondMembersView> => {
|
|
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);
|
|
});
|
|
});
|