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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user