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:
@@ -15,6 +15,7 @@ import { DkvModule } from './dkv/dkv.module';
|
||||
import { CertManagerModule } from './cert-manager/cert-manager.module';
|
||||
import { FavoritesModule } from './favorites/favorites.module';
|
||||
import { DomaincheckModule } from './domaincheck/domaincheck.module';
|
||||
import { GroupsModule } from './groups/groups.module';
|
||||
import { ModuleRegistryModule } from './module-registry/module-registry.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
@@ -35,6 +36,7 @@ import { UserModule } from './user/user.module';
|
||||
MailModule,
|
||||
LdapModule,
|
||||
ModuleRegistryModule,
|
||||
GroupsModule,
|
||||
DomaincheckModule,
|
||||
CertManagerModule,
|
||||
DashboardModule,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsArray, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO für das manuelle Hinzufügen von Mitgliedern zu einer Gruppe.
|
||||
*/
|
||||
export class AddGroupMembersDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
userIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO für das Anlegen einer Gruppe (PERM-01).
|
||||
*
|
||||
* Leerraum wird im Service getrimmt; ein leerer oder nur aus Leerzeichen
|
||||
* bestehender Name wird dort explizit als BadRequestException abgelehnt
|
||||
* (Trim passiert NACH der class-validator-Prüfung, deshalb reicht hier
|
||||
* @IsNotEmpty auf den Rohwert nicht aus, um " " abzufangen).
|
||||
*/
|
||||
export class CreateGroupDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO für das Aktualisieren einer Gruppe: Umbenennen, Standardmarkierung
|
||||
* setzen/entfernen (D-13), AD-Bindung setzen/lösen (ldapDn: null löst).
|
||||
*
|
||||
* @IsOptional() lässt sowohl undefined als auch null unvalidiert durch —
|
||||
* das ist die Voraussetzung dafür, dass ldapDn: null (Lösen der AD-Bindung)
|
||||
* gültig bleibt, obwohl @IsString() sonst null ablehnen würde.
|
||||
*/
|
||||
export class UpdateGroupDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ldapDn?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Request } from 'express';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { AddGroupMembersDto } from './dto/add-group-members.dto';
|
||||
import { CreateGroupDto } from './dto/create-group.dto';
|
||||
import { UpdateGroupDto } from './dto/update-group.dto';
|
||||
import { GroupsService } from './groups.service';
|
||||
|
||||
/**
|
||||
* REST-Controller für die Gruppenverwaltung (PERM-01, D-14).
|
||||
*
|
||||
* tenantId kommt ausschließlich aus dem JWT (req.tenantId ?? req.user?.tenantId),
|
||||
* niemals aus Body/Params (T-03-04). Jede Route ist rollengeschützt —
|
||||
* identisch zu den Modul-Aktivierungsrouten in ModuleRegistryController.
|
||||
*/
|
||||
@Controller('groups')
|
||||
export class GroupsController {
|
||||
constructor(private readonly groupsService: GroupsService) {}
|
||||
|
||||
private getTenantId(req: Request): string {
|
||||
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /groups
|
||||
* Liste aller Gruppen des Mandanten, alphabetisch, mit Mitgliederzahl.
|
||||
*/
|
||||
@Get()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async list(@Req() req: Request) {
|
||||
return this.groupsService.listForTenant(this.getTenantId(req));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /groups
|
||||
* Legt eine neue Gruppe im Mandanten an.
|
||||
*/
|
||||
@Post()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async create(@Body() dto: CreateGroupDto, @Req() req: Request) {
|
||||
return this.groupsService.create(this.getTenantId(req), dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /groups/:id
|
||||
* Umbenennen, Standardmarkierung setzen/entfernen, AD-Bindung setzen/lösen.
|
||||
*/
|
||||
@Patch(':id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateGroupDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.groupsService.update(this.getTenantId(req), id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /groups/:id
|
||||
* Löscht die Gruppe; Mitgliedschaften und Grants folgen per Cascade (D-17).
|
||||
*/
|
||||
@Delete(':id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async remove(@Param('id') id: string, @Req() req: Request) {
|
||||
return this.groupsService.remove(this.getTenantId(req), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /groups/:id/impact
|
||||
* Zahlenmaterial für den Löschdialog (D-17): { memberCount, grantCount }.
|
||||
*/
|
||||
@Get(':id/impact')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async impact(@Param('id') id: string, @Req() req: Request) {
|
||||
return this.groupsService.getImpact(this.getTenantId(req), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /groups/:id/members
|
||||
* Mitgliederliste inkl. Kern-Benutzerdaten.
|
||||
*/
|
||||
@Get(':id/members')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async members(@Param('id') id: string, @Req() req: Request) {
|
||||
return this.groupsService.listMembers(this.getTenantId(req), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /groups/:id/members
|
||||
* Fügt Mitglieder manuell hinzu (source: MANUAL).
|
||||
*/
|
||||
@Post(':id/members')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async addMembers(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AddGroupMembersDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.groupsService.addMembers(this.getTenantId(req), id, dto.userIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /groups/:id/members/:userId
|
||||
* Entfernt ein manuell hinzugefügtes Mitglied (LDAP-Mitgliedschaften
|
||||
* bleiben unberührt, D-19).
|
||||
*/
|
||||
@Delete(':id/members/:userId')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async removeMember(
|
||||
@Param('id') id: string,
|
||||
@Param('userId') userId: string,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
await this.groupsService.removeMember(this.getTenantId(req), id, userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GroupsController } from './groups.controller';
|
||||
import { GroupsService } from './groups.service';
|
||||
|
||||
/**
|
||||
* NestJS-Modul für die Gruppenverwaltung (PERM-01, PERM-06).
|
||||
*
|
||||
* Exportiert GroupsService, damit UserModule (Plan 15-02 Task 2) darüber
|
||||
* addUserToDefaultGroup aufrufen kann — der einzige Codepfad für die
|
||||
* automatische Standardgruppen-Mitgliedschaft (D-11/D-12).
|
||||
*/
|
||||
@Module({
|
||||
controllers: [GroupsController],
|
||||
providers: [GroupsService],
|
||||
exports: [GroupsService],
|
||||
})
|
||||
export class GroupsModule {}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { MembershipSource } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Service für Gruppen-CRUD, Mitgliederverwaltung und die automatische
|
||||
* Standardgruppen-Mitgliedschaft (PERM-01/PERM-06, D-11/D-12/D-13/D-17/D-19).
|
||||
*
|
||||
* Zentrale Regel: jede Lookup-Query mit einer Gruppen-ID filtert zusätzlich
|
||||
* auf tenantId, nach dem Ownership-Check-Muster aus
|
||||
* DashboardService.removeWidget — eine ID aus einem fremden Mandanten
|
||||
* liefert nie einen Treffer, sondern NotFoundException. RLS (aus 15-01) ist
|
||||
* das zweite Netz, nicht der primäre Schutz (T-15-02/T-15-12).
|
||||
*/
|
||||
@Injectable()
|
||||
export class GroupsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Gruppen eines Mandanten, alphabetisch nach Name, jeweils mit der
|
||||
* Mitgliederzahl. Ein Mandant ohne Gruppen liefert ein leeres Array.
|
||||
*/
|
||||
async listForTenant(tenantId: string) {
|
||||
const groups = await this.prisma.group.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { memberships: true } } },
|
||||
});
|
||||
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
tenantId: g.tenantId,
|
||||
name: g.name,
|
||||
ldapDn: g.ldapDn,
|
||||
isDefault: g.isDefault,
|
||||
createdAt: g.createdAt,
|
||||
updatedAt: g.updatedAt,
|
||||
memberCount: g._count.memberships,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt eine Gruppe im Mandanten an. Führender/abschließender Leerraum
|
||||
* wird entfernt; der Name selbst wird weder normalisiert noch
|
||||
* kleingeschrieben (bewusst anders als Benutzernamen). P2002 (Unique-
|
||||
* Verletzung auf (tenantId, name)) wird in ConflictException übersetzt —
|
||||
* der Unique-Index aus 15-01 ist der eigentliche Durchsetzungspunkt.
|
||||
*/
|
||||
async create(tenantId: string, data: { name: string }) {
|
||||
const name = data.name?.trim();
|
||||
if (!name) {
|
||||
throw new BadRequestException('Gruppenname darf nicht leer sein');
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.prisma.group.create({
|
||||
data: { tenantId, name },
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2002') {
|
||||
throw new ConflictException(
|
||||
`Eine Gruppe mit dem Namen '${name}' existiert bereits in diesem Mandanten`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt eine Gruppe, mandantengescoped. Eine ID aus einem fremden
|
||||
* Mandanten liefert NotFoundException statt eines Treffers.
|
||||
*/
|
||||
private async findOwned(tenantId: string, id: string) {
|
||||
const group = await this.prisma.group.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException(`Gruppe '${id}' nicht gefunden`);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert Name, Standardmarkierung und/oder AD-Bindung.
|
||||
*
|
||||
* isDefault:true läuft in einer Transaktion: zuerst updateMany auf alle
|
||||
* Gruppen des Mandanten mit isDefault:false, dann update der Zielgruppe
|
||||
* auf true (D-13). Der partielle Unique-Index Group_one_default_per_tenant
|
||||
* aus 15-01 ist das Sicherheitsnetz gegen parallele Aufrufe, die
|
||||
* Transaktion ist der normale Pfad. isDefault:false schaltet die
|
||||
* Markierung nur an dieser einen Gruppe ab, ohne sie irgendwo anders zu
|
||||
* setzen. ldapDn:null löst eine AD-Bindung.
|
||||
*/
|
||||
async update(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
data: { name?: string; isDefault?: boolean; ldapDn?: string | null },
|
||||
) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
const updateData: {
|
||||
name?: string;
|
||||
ldapDn?: string | null;
|
||||
isDefault?: boolean;
|
||||
} = {};
|
||||
|
||||
if (data.name !== undefined) {
|
||||
const trimmed = data.name.trim();
|
||||
if (!trimmed) {
|
||||
throw new BadRequestException('Gruppenname darf nicht leer sein');
|
||||
}
|
||||
updateData.name = trimmed;
|
||||
}
|
||||
if (data.ldapDn !== undefined) {
|
||||
updateData.ldapDn = data.ldapDn;
|
||||
}
|
||||
|
||||
try {
|
||||
if (data.isDefault === true) {
|
||||
const [, updated] = await this.prisma.$transaction([
|
||||
this.prisma.group.updateMany({
|
||||
where: { tenantId, isDefault: true },
|
||||
data: { isDefault: false },
|
||||
}),
|
||||
this.prisma.group.update({
|
||||
where: { id },
|
||||
data: { ...updateData, isDefault: true },
|
||||
}),
|
||||
]);
|
||||
return updated;
|
||||
}
|
||||
|
||||
if (data.isDefault === false) {
|
||||
updateData.isDefault = false;
|
||||
}
|
||||
|
||||
return await this.prisma.group.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2002') {
|
||||
throw new ConflictException(
|
||||
`Eine Gruppe mit dem Namen '${updateData.name}' existiert bereits in diesem Mandanten`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahlenmaterial für den Löschdialog (D-17): Mitgliederzahl und Anzahl
|
||||
* der Modul-Freigaben dieser Gruppe.
|
||||
*/
|
||||
async getImpact(tenantId: string, id: string) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
const [memberCount, grantCount] = await Promise.all([
|
||||
this.prisma.groupMembership.count({ where: { groupId: id } }),
|
||||
this.prisma.moduleGrant.count({ where: { groupId: id } }),
|
||||
]);
|
||||
|
||||
return { memberCount, grantCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht eine Gruppe. Mitgliedschaften und Grants verschwinden über die
|
||||
* in 15-01 definierten onDelete:Cascade-Regeln — kein zusätzliches
|
||||
* anwendungsseitiges Aufräumen, sonst gäbe es zwei Wahrheiten über das
|
||||
* Aufräumverhalten. Ein zweiter Aufruf auf dieselbe ID (z.B. bei zwei
|
||||
* gleichzeitigen DELETE-Anfragen) wirft NotFoundException statt eines
|
||||
* unbehandelten P2025-Fehlers.
|
||||
*/
|
||||
async remove(tenantId: string, id: string) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
try {
|
||||
return await this.prisma.group.delete({ where: { id } });
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2025') {
|
||||
throw new NotFoundException(`Gruppe '${id}' nicht gefunden`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mitglieder einer Gruppe inkl. Kern-Benutzerdaten, für die Anzeige im
|
||||
* Gruppen-Detail (D-14).
|
||||
*/
|
||||
async listMembers(tenantId: string, id: string) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
return this.prisma.groupMembership.findMany({
|
||||
where: { groupId: id },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true, displayName: true, email: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt Mitglieder manuell hinzu (source: MANUAL). Eine userId eines
|
||||
* fremden Mandanten wird übersprungen und nicht aufgenommen (T-15-12,
|
||||
* Cross-Tenant-Grant-Injection-Schutz). Bereits vorhandene Mitgliedschaften
|
||||
* sind über skipDuplicates gegen @@unique([groupId, userId]) folgenlos.
|
||||
*/
|
||||
async addMembers(tenantId: string, id: string, userIds: string[]) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
const validUsers = await this.prisma.user.findMany({
|
||||
where: { id: { in: userIds }, tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
const validIds = validUsers.map((u) => u.id);
|
||||
if (validIds.length === 0) {
|
||||
return { added: 0 };
|
||||
}
|
||||
|
||||
const result = await this.prisma.groupMembership.createMany({
|
||||
data: validIds.map((userId) => ({
|
||||
groupId: id,
|
||||
userId,
|
||||
source: MembershipSource.MANUAL,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
return { added: result.count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt ein Mitglied — ausschließlich Mitgliedschaften mit
|
||||
* source: MANUAL (D-19: eine über AD gesteuerte Mitgliedschaft entfernt
|
||||
* ausschließlich der Sync, nie diese Route). Für ein nicht vorhandenes
|
||||
* Mitglied folgenlos, wirft nicht.
|
||||
*/
|
||||
async removeMember(tenantId: string, id: string, userId: string) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
await this.prisma.groupMembership.deleteMany({
|
||||
where: { groupId: id, userId, source: MembershipSource.MANUAL },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt eine Mitgliedschaft in der als Standard markierten Gruppe des
|
||||
* Mandanten an (D-11/D-12/D-13). Existiert keine markierte Standardgruppe,
|
||||
* tut die Methode nichts und wirft nicht — wird von UserService.create
|
||||
* aufgerufen (Plan 15-02 Task 2), muss deshalb aus GroupsModule
|
||||
* exportiert sein.
|
||||
*/
|
||||
async addUserToDefaultGroup(tenantId: string, userId: string) {
|
||||
const defaultGroup = await this.prisma.group.findFirst({
|
||||
where: { tenantId, isDefault: true },
|
||||
});
|
||||
if (!defaultGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.groupMembership.createMany({
|
||||
data: [{ groupId: defaultGroup.id, userId, source: MembershipSource.MANUAL }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user