feat(15-02): GroupsModule — CRUD für Gruppen, Mitgliedschaften und Löschauswirkung

- GroupsService: listForTenant/create/update/remove/getImpact/listMembers/addMembers/removeMember/addUserToDefaultGroup, jede Query tenantId-gescoped (T-15-02/T-15-12)
- isDefault:true läuft in einer Transaktion (updateMany+update), D-13
- getImpact liefert { memberCount, grantCount } für den Löschdialog (D-17)
- removeMember beschränkt sich auf source:MANUAL (D-19)
- GroupsController: 8 rollengeschützte Routen unter /groups
- 19 Tests in groups.service.spec.ts, hand-rolled In-Memory-Fake
This commit is contained in:
2026-08-04 15:21:41 +02:00
parent 79c83eae5f
commit 69494d7549
8 changed files with 914 additions and 0 deletions
+432
View File
@@ -0,0 +1,432 @@
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { MembershipSource } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { GroupsService } from './groups.service';
/**
* GroupsService.spec — TDD-RED-first Beweis für PERM-01 (D-13/D-17/D-19)
* und die tenantId-Ownership-Invariante (T-15-02/T-15-12).
*
* Hand-rolled In-Memory-Fake im Stil von tender-saved-search.service.spec.ts:
* keine Live-DB, simuliert P2002 (Unique-Verletzung) und P2025
* (Record-not-found bei einem zweiten remove()-Aufruf) exakt wie ein
* echter Postgres-Client via Prisma-Fehlercodes.
*/
function makeFakePrisma() {
const groups = new Map<string, any>();
const memberships = new Map<string, any>();
const grants = new Map<string, any>();
const users = new Map<string, any>();
let groupCounter = 0;
let membershipCounter = 0;
function findGroupByTenantAndName(tenantId: string, name: string, excludeId?: string) {
return Array.from(groups.values()).find(
(g) => g.tenantId === tenantId && g.name === name && g.id !== excludeId,
);
}
function throwUnique(): never {
const err: any = new Error('Unique constraint failed');
err.code = 'P2002';
throw err;
}
function throwNotFound(): never {
const err: any = new Error('Record to update/delete not found');
err.code = 'P2025';
throw err;
}
return {
__seedUser(user: { id: string; tenantId: string }) {
users.set(user.id, user);
},
__seedGrant(grant: { id: string; groupId: string }) {
grants.set(grant.id, grant);
},
group: {
findMany: async ({ where, include }: any) => {
let rows: any[] = Array.from(groups.values()).filter(
(g) => g.tenantId === where.tenantId,
);
rows = [...rows].sort((a, b) => a.name.localeCompare(b.name));
if (include?._count) {
rows = rows.map((g) => ({
...g,
_count: {
memberships: Array.from(memberships.values()).filter(
(m) => m.groupId === g.id,
).length,
},
}));
}
return rows;
},
findFirst: async ({ where }: any) => {
return (
Array.from(groups.values()).find(
(g) =>
(where.id === undefined || g.id === where.id) &&
g.tenantId === where.tenantId &&
(where.isDefault === undefined || g.isDefault === where.isDefault),
) ?? null
);
},
create: async ({ data }: any) => {
if (findGroupByTenantAndName(data.tenantId, data.name)) throwUnique();
groupCounter += 1;
const record = {
id: `g-${groupCounter}`,
ldapDn: null,
isDefault: false,
createdAt: new Date(),
updatedAt: new Date(),
...data,
};
groups.set(record.id, record);
return record;
},
update: async ({ where, data }: any) => {
const existing = groups.get(where.id);
if (!existing) throwNotFound();
if (data.name && findGroupByTenantAndName(existing.tenantId, data.name, existing.id)) {
throwUnique();
}
const record = { ...existing, ...data, updatedAt: new Date() };
groups.set(where.id, record);
return record;
},
updateMany: async ({ where, data }: any) => {
let count = 0;
for (const g of groups.values()) {
if (
g.tenantId === where.tenantId &&
(where.isDefault === undefined || g.isDefault === where.isDefault)
) {
Object.assign(g, data);
count += 1;
}
}
return { count };
},
delete: async ({ where }: any) => {
const existing = groups.get(where.id);
if (!existing) throwNotFound();
groups.delete(where.id);
return existing;
},
},
groupMembership: {
findMany: async ({ where }: any) =>
Array.from(memberships.values()).filter((m) => m.groupId === where.groupId),
createMany: async ({ data, skipDuplicates }: any) => {
let count = 0;
for (const item of data) {
const exists = Array.from(memberships.values()).find(
(m) => m.groupId === item.groupId && m.userId === item.userId,
);
if (exists) {
if (skipDuplicates) continue;
throwUnique();
}
membershipCounter += 1;
memberships.set(`m-${membershipCounter}`, {
id: `m-${membershipCounter}`,
createdAt: new Date(),
...item,
});
count += 1;
}
return { count };
},
deleteMany: async ({ where }: any) => {
let count = 0;
for (const [key, m] of memberships.entries()) {
if (
m.groupId === where.groupId &&
m.userId === where.userId &&
(where.source === undefined || m.source === where.source)
) {
memberships.delete(key);
count += 1;
}
}
return { count };
},
count: async ({ where }: any) =>
Array.from(memberships.values()).filter((m) => m.groupId === where.groupId).length,
},
moduleGrant: {
count: async ({ where }: any) =>
Array.from(grants.values()).filter((g) => g.groupId === where.groupId).length,
},
user: {
findMany: async ({ where }: any) => {
const ids: string[] = where.id.in;
return Array.from(users.values()).filter(
(u) => ids.includes(u.id) && u.tenantId === where.tenantId,
);
},
},
$transaction: async (ops: Promise<any>[]) => Promise.all(ops),
};
}
describe('GroupsService', () => {
// --- listForTenant ------------------------------------------------------
it('listForTenant() liefert die Gruppen nach name aufsteigend, mit memberCount', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'Zebra' });
const alpha = await service.create('t1', { name: 'Alpha' });
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
await service.addMembers('t1', alpha.id, ['u1']);
const result = await service.listForTenant('t1');
expect(result.map((g) => g.name)).toEqual(['Alpha', 'Zebra']);
expect(result[0].memberCount).toBe(1);
expect(result[1].memberCount).toBe(0);
});
it('listForTenant() eines Mandanten ohne Gruppen liefert ein leeres Array', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
expect(await service.listForTenant('t-leer')).toEqual([]);
});
// --- create --------------------------------------------------------------
it('create() trimmt führenden und abschließenden Leerraum', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const result = await service.create('t1', { name: ' Vertrieb ' });
expect(result.name).toBe('Vertrieb');
});
it('create() mit leerem oder nur aus Leerzeichen bestehendem Namen wirft BadRequestException', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await expect(service.create('t1', { name: '' })).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(service.create('t1', { name: ' ' })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('create() mit einem im Mandanten bereits vergebenen Namen wirft ConflictException (P2002)', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'Vertrieb' });
await expect(service.create('t1', { name: 'Vertrieb' })).rejects.toBeInstanceOf(
ConflictException,
);
});
it('create() mit demselben Namen in einem anderen Mandanten ist erfolgreich', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'Vertrieb' });
const result = await service.create('t2', { name: 'Vertrieb' });
expect(result.tenantId).toBe('t2');
});
it('encoding/PERM-01: Gruppennamen werden nicht normalisiert oder kleingeschrieben — "Vertrieb" und "vertrieb" sind zwei Gruppen', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'Vertrieb' });
const lower = await service.create('t1', { name: 'vertrieb' });
expect(lower.name).toBe('vertrieb');
});
// --- update ----------------------------------------------------------------
it('update() benennt um; eine ID eines fremden Mandanten wirft NotFoundException', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'Alt' });
const updated = await service.update('t1', group.id, { name: 'Neu' });
expect(updated.name).toBe('Neu');
await expect(
service.update('t-fremd', group.id, { name: 'Uebernahme' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('update() mit isDefault:true setzt die Markierung und entfernt sie von jeder anderen Gruppe desselben Mandanten', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const a = await service.create('t1', { name: 'A' });
const b = await service.create('t1', { name: 'B' });
await service.update('t1', a.id, { isDefault: true });
const updatedB = await service.update('t1', b.id, { isDefault: true });
expect(updatedB.isDefault).toBe(true);
const list = await service.listForTenant('t1');
const groupA = list.find((g) => g.id === a.id)!;
expect(groupA.isDefault).toBe(false);
});
it('update() mit isDefault:false schaltet die Markierung ab, ohne sie irgendwo anders zu setzen', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const a = await service.create('t1', { name: 'A' });
await service.update('t1', a.id, { isDefault: true });
const result = await service.update('t1', a.id, { isDefault: false });
expect(result.isDefault).toBe(false);
});
it('update() mit ldapDn bindet an eine AD-Gruppe; ldapDn:null löst die Bindung', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
const bound = await service.update('t1', group.id, {
ldapDn: 'CN=A,OU=Groups,DC=ctl,DC=local',
});
expect(bound.ldapDn).toBe('CN=A,OU=Groups,DC=ctl,DC=local');
const unbound = await service.update('t1', group.id, { ldapDn: null });
expect(unbound.ldapDn).toBeNull();
});
// --- getImpact ---------------------------------------------------------
it('getImpact() liefert { memberCount, grantCount }; eine ID eines fremden Mandanten wirft NotFoundException', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
await service.addMembers('t1', group.id, ['u1']);
prisma.__seedGrant({ id: 'grant-1', groupId: group.id });
const impact = await service.getImpact('t1', group.id);
expect(impact).toEqual({ memberCount: 1, grantCount: 1 });
await expect(service.getImpact('t-fremd', group.id)).rejects.toBeInstanceOf(
NotFoundException,
);
});
// --- remove --------------------------------------------------------------
it('remove() löscht die Gruppe; ein zweiter Aufruf auf dieselbe ID wirft NotFoundException statt eines unbehandelten Prisma-Fehlers', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
await service.remove('t1', group.id);
await expect(service.remove('t1', group.id)).rejects.toBeInstanceOf(NotFoundException);
});
// --- addMembers / removeMember -------------------------------------------
it('addMembers() legt fehlende Mitgliedschaften mit source:MANUAL an und ist für bereits vorhandene folgenlos', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
const first = await service.addMembers('t1', group.id, ['u1']);
expect(first.added).toBe(1);
const second = await service.addMembers('t1', group.id, ['u1']);
expect(second.added).toBe(0);
const members = await service.listMembers('t1', group.id);
expect(members).toHaveLength(1);
expect(members[0].source).toBe(MembershipSource.MANUAL);
});
it('addMembers() mit einer userId eines fremden Mandanten überspringt diese ID', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
prisma.__seedUser({ id: 'u-fremd', tenantId: 't2' });
const result = await service.addMembers('t1', group.id, ['u-fremd']);
expect(result.added).toBe(0);
expect(await service.listMembers('t1', group.id)).toEqual([]);
});
it('removeMember() entfernt ausschließlich Mitgliedschaften mit source:MANUAL und lässt LDAP-Mitgliedschaften unberührt', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
prisma.__seedUser({ id: 'u-manual', tenantId: 't1' });
prisma.__seedUser({ id: 'u-ldap', tenantId: 't1' });
await service.addMembers('t1', group.id, ['u-manual']);
// LDAP-Mitgliedschaft direkt im Fake seeden (kein Service-Pfad dafür in diesem Plan)
await (prisma as any).groupMembership.createMany({
data: [{ groupId: group.id, userId: 'u-ldap', source: MembershipSource.LDAP }],
skipDuplicates: true,
});
await service.removeMember('t1', group.id, 'u-manual');
await service.removeMember('t1', group.id, 'u-ldap');
const remaining = await service.listMembers('t1', group.id);
expect(remaining).toHaveLength(1);
expect(remaining[0].userId).toBe('u-ldap');
});
it('removeMember() für ein nicht vorhandenes Mitglied ist folgenlos und wirft nicht', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
await expect(service.removeMember('t1', group.id, 'kein-mitglied')).resolves.not.toThrow();
});
// --- addUserToDefaultGroup -------------------------------------------------
it('addUserToDefaultGroup() legt eine Mitgliedschaft in der Gruppe mit isDefault:true an', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'Alle Benutzer' });
await service.update('t1', group.id, { isDefault: true });
await service.addUserToDefaultGroup('t1', 'u1');
const members = await service.listMembers('t1', group.id);
expect(members.map((m: any) => m.userId)).toEqual(['u1']);
});
it('addUserToDefaultGroup() bei einem Mandanten ohne markierte Standardgruppe tut nichts und wirft nicht', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'Keine Standardgruppe' });
await expect(service.addUserToDefaultGroup('t1', 'u1')).resolves.not.toThrow();
});
});