feat(15-02): automatische Standardgruppen-Mitgliedschaft an genau einem Ort
- 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
This commit is contained in:
@@ -1,9 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GroupsModule } from '../groups/groups.module';
|
||||||
import { AdminSeedService } from './admin-seed.service';
|
import { AdminSeedService } from './admin-seed.service';
|
||||||
import { UserController } from './user.controller';
|
import { UserController } from './user.controller';
|
||||||
import { UserService } from './user.service';
|
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({
|
@Module({
|
||||||
|
imports: [GroupsModule],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService, AdminSeedService],
|
providers: [UserService, AdminSeedService],
|
||||||
exports: [UserService],
|
exports: [UserService],
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
|
import { GroupsService } from '../groups/groups.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
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)
|
* Find user by username. Uses UNSCOPED Prisma (not tenant-scoped)
|
||||||
@@ -28,6 +34,17 @@ export class UserService {
|
|||||||
/**
|
/**
|
||||||
* Create a new user with hashed password.
|
* Create a new user with hashed password.
|
||||||
* Username is normalized to lowercase so login is case-insensitive.
|
* 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: {
|
async create(data: {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -40,13 +57,25 @@ export class UserService {
|
|||||||
ldapDn?: string;
|
ldapDn?: string;
|
||||||
}) {
|
}) {
|
||||||
const { password, ...rest } = data;
|
const { password, ...rest } = data;
|
||||||
return this.prisma.user.create({
|
const created = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
...rest,
|
...rest,
|
||||||
username: rest.username.toLowerCase(),
|
username: rest.username.toLowerCase(),
|
||||||
passwordHash: password ? await argon2.hash(password) : null,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user