From 33838dde398574b84c3291114a6ea385c2e67547 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 15:23:01 +0200 Subject: [PATCH] feat(15-02): automatische Standardgruppen-Mitgliedschaft an genau einem Ort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserService.create ruft nach der Anlage GroupsService.addUserToDefaultGroup auf (D-11/D-12) — einziger Erzeugungspunkt für Benutzer, erbt LdapService ohne eigene Kopie der Regel - try/catch mit Logger: gescheiterte Gruppenzuordnung bricht weder die Benutzeranlage noch einen LDAP-Sync-Lauf ab (T-15-14) - UserModule importiert GroupsModule, keine Zirkularität - 4 Tests in user.service.spec.ts; ldap.service.ts unverändert --- apps/api/src/user/user.module.ts | 8 +++ apps/api/src/user/user.service.spec.ts | 74 ++++++++++++++++++++++++++ apps/api/src/user/user.service.ts | 35 ++++++++++-- 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/user/user.service.spec.ts diff --git a/apps/api/src/user/user.module.ts b/apps/api/src/user/user.module.ts index a4c0f33..9bec42c 100644 --- a/apps/api/src/user/user.module.ts +++ b/apps/api/src/user/user.module.ts @@ -1,9 +1,17 @@ import { Module } from '@nestjs/common'; +import { GroupsModule } from '../groups/groups.module'; import { AdminSeedService } from './admin-seed.service'; import { UserController } from './user.controller'; import { UserService } from './user.service'; +/** + * Importiert GroupsModule für UserService.create's Standardgruppen- + * Mitgliedschaft (D-11/D-12). GroupsModule importiert seinerseits + * UserModule nicht — GroupsService greift für Benutzerprüfungen direkt + * auf PrismaService zu, keine Zirkularität. + */ @Module({ + imports: [GroupsModule], controllers: [UserController], providers: [UserService, AdminSeedService], exports: [UserService], diff --git a/apps/api/src/user/user.service.spec.ts b/apps/api/src/user/user.service.spec.ts new file mode 100644 index 0000000..da612b6 --- /dev/null +++ b/apps/api/src/user/user.service.spec.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UserService } from './user.service'; + +/** + * UserService.create — Standardgruppen-Mitgliedschaft (D-11/D-12, PERM-06). + * + * UserService.create ist der einzige Erzeugungspunkt für Benutzer im + * Backend (auch LdapService.upsertMappedUser/importUsersByDn rufen + * ausschließlich hierüber auf, unverändert in diesem Plan). Diese Tests + * decken ausschließlich die neue Standardgruppen-Anbindung ab, mit + * gemocktem PrismaService und gemocktem GroupsService. + */ +describe('UserService.create — Standardgruppen-Mitgliedschaft (D-11/D-12)', () => { + let prisma: any; + let groupsService: any; + let service: UserService; + + const createdUser = { id: 'u1', tenantId: 't1', username: 'alice' }; + const baseData = { + username: 'Alice', + email: 'alice@example.com', + tenantId: 't1', + }; + + beforeEach(() => { + vi.clearAllMocks(); + prisma = { + user: { + create: vi.fn().mockResolvedValue(createdUser), + }, + }; + groupsService = { + addUserToDefaultGroup: vi.fn().mockResolvedValue(undefined), + }; + service = new UserService(prisma, groupsService); + }); + + it('ruft nach der Benutzeranlage genau einmal GroupsService.addUserToDefaultGroup mit derselben tenantId und der frisch erzeugten userId auf', async () => { + const result = await service.create(baseData); + + expect(result).toEqual(createdUser); + expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1); + expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledWith('t1', 'u1'); + }); + + it('legt den Benutzer bei einem Mandanten ohne markierte Standardgruppe trotzdem an — addUserToDefaultGroup bleibt folgenlos, die Anlage schlägt nicht fehl', async () => { + groupsService.addUserToDefaultGroup.mockResolvedValue(undefined); + + const result = await service.create(baseData); + + expect(result).toEqual(createdUser); + expect(prisma.user.create).toHaveBeenCalledTimes(1); + expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1); + }); + + it('gibt den Benutzer trotz werfendem addUserToDefaultGroup zurück — der Fehler wird protokolliert, nicht propagiert', async () => { + groupsService.addUserToDefaultGroup.mockRejectedValue(new Error('boom')); + + await expect(service.create(baseData)).resolves.toEqual(createdUser); + }); + + it('gibt weiterhin den erzeugten Benutzerdatensatz zurück; die Signatur bleibt unverändert', async () => { + const result = await service.create(baseData); + + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + username: 'alice', + email: 'alice@example.com', + tenantId: 't1', + }), + }); + expect(result).toHaveProperty('id', 'u1'); + }); +}); diff --git a/apps/api/src/user/user.service.ts b/apps/api/src/user/user.service.ts index 8a11a51..57444c8 100644 --- a/apps/api/src/user/user.service.ts +++ b/apps/api/src/user/user.service.ts @@ -1,10 +1,16 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import * as argon2 from 'argon2'; +import { GroupsService } from '../groups/groups.service'; import { PrismaService } from '../prisma/prisma.service'; @Injectable() export class UserService { - constructor(private prisma: PrismaService) {} + private readonly logger = new Logger(UserService.name); + + constructor( + private prisma: PrismaService, + private readonly groupsService: GroupsService, + ) {} /** * Find user by username. Uses UNSCOPED Prisma (not tenant-scoped) @@ -28,6 +34,17 @@ export class UserService { /** * Create a new user with hashed password. * Username is normalized to lowercase so login is case-insensitive. + * + * D-11/D-12 (PERM-06): dies ist der EINZIGE Erzeugungspunkt für Benutzer + * im gesamten Backend — sowohl der Admin-UserController als auch + * LdapService.upsertMappedUser und LdapService.importUsersByDn erzeugen + * Benutzer ausschließlich hierüber. Nach der Anlage wird deshalb genau + * an dieser einen Stelle die Mitgliedschaft in der markierten + * Standardgruppe des Mandanten hergestellt, statt die Regel in jedem + * Aufrufer zu wiederholen. Der Aufruf liegt in try/catch mit Logger + * (T-15-14): eine gescheiterte Gruppenzuordnung darf weder die + * Benutzeranlage noch einen LDAP-Sync-Lauf über hunderte Benutzer + * abbrechen. */ async create(data: { username: string; @@ -40,13 +57,25 @@ export class UserService { ldapDn?: string; }) { const { password, ...rest } = data; - return this.prisma.user.create({ + const created = await this.prisma.user.create({ data: { ...rest, username: rest.username.toLowerCase(), passwordHash: password ? await argon2.hash(password) : null, }, }); + + try { + await this.groupsService.addUserToDefaultGroup(created.tenantId, created.id); + } catch (err) { + this.logger.error( + `Standardgruppen-Zuordnung fehlgeschlagen für Benutzer '${created.id}': ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + + return created; } /**