feat(16-02): server-side name lock for imported groups + internalName (D-03/D-04/D-07)

- GroupsService.update() rejects `name` with BadRequestException when the
  loaded group carries a set ldapObjectGuid (imported groups) — a real
  backend invariant, not a UI-only disable
- internalName is settable/clearable on any group; empty/whitespace-only
  values normalize to null instead of an empty display name
- listForTenant() now projects internalName alongside name
- UpdateGroupDto drops ldapDn (D-07: no more codepath binds a local group
  to AD via this route) and gains internalName?: string | null
- 9 new test cases in groups.service.spec.ts (name lock, internalName
  set/clear/idempotent/local-group/unicode, listForTenant projection);
  stale ldapDn update() test removed (behavior intentionally deleted)
This commit is contained in:
2026-08-06 15:58:28 +02:00
parent 2ef9b8638c
commit 253da91ba9
3 changed files with 157 additions and 21 deletions
+30 -7
View File
@@ -45,6 +45,7 @@ export class GroupsService {
id: g.id,
tenantId: g.tenantId,
name: g.name,
internalName: g.internalName,
ldapDn: g.ldapDn,
isDefault: g.isDefault,
createdAt: g.createdAt,
@@ -95,7 +96,7 @@ export class GroupsService {
}
/**
* Aktualisiert Name, Standardmarkierung und/oder AD-Bindung.
* Aktualisiert Name, Standardmarkierung und/oder internen Anzeigenamen.
*
* isDefault:true läuft in einer Transaktion: zuerst updateMany auf alle
* Gruppen des Mandanten mit isDefault:false, dann update der Zielgruppe
@@ -103,30 +104,52 @@ export class GroupsService {
* 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.
* setzen.
*
* Namenssperre (D-03/D-07): trägt die geladene Gruppe einen gesetzten
* ldapObjectGuid, ist sie aus dem Verzeichnis importiert — ein `name`
* im Request wird dann mit BadRequestException abgelehnt, BEVOR die
* Leerstring-Prüfung läuft. Das ist eine Backend-Invariante, kein UI-
* Feld-Disable: ein direkter API-Aufruf kommt an ihr nicht vorbei.
*
* internalName (D-04) läuft unabhängig von dieser Sperre — jede Gruppe,
* importiert oder lokal, darf ihn setzen. undefined lässt die Spalte
* unangetastet, null oder ein leerer/nur-Leerzeichen-String normalisiert
* auf null (nie ein leerer Anzeigename), ein nicht-leerer String wird
* getrimmt gespeichert.
*/
async update(
tenantId: string,
id: string,
data: { name?: string; isDefault?: boolean; ldapDn?: string | null },
data: { name?: string; isDefault?: boolean; internalName?: string | null },
) {
await this.findOwned(tenantId, id);
const existing = await this.findOwned(tenantId, id);
const updateData: {
name?: string;
ldapDn?: string | null;
internalName?: string | null;
isDefault?: boolean;
} = {};
if (data.name !== undefined) {
if (existing.ldapObjectGuid) {
throw new BadRequestException(
'Der Name einer aus dem Verzeichnis übernommenen Gruppe wird dort gepflegt und kann hier nicht geändert werden',
);
}
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;
if (data.internalName !== undefined) {
if (data.internalName === null) {
updateData.internalName = null;
} else {
const trimmed = data.internalName.trim();
updateData.internalName = trimmed === '' ? null : trimmed;
}
}
try {