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
+114 -9
View File
@@ -108,6 +108,8 @@ function makeFakePrisma() {
const record = {
id: `g-${groupCounter}`,
ldapDn: null,
internalName: null,
ldapObjectGuid: null,
isDefault: false,
createdAt: new Date(),
updatedAt: new Date(),
@@ -373,18 +375,121 @@ describe('GroupsService', () => {
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);
// D-07: update() bietet seit diesem Plan absichtlich keinen ldapDn-Parameter
// mehr — eine lokal angelegte Gruppe kann nicht mehr nachträglich an eine
// AD-Gruppe gebunden werden, und eine bestehende Bindung kann nicht mehr
// über diesen Weg gelöst werden. Der frühere Test dafür entfällt ersatzlos.
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',
// --- update() — Namenssperre und interner Name (D-03/D-04) ---------------
describe('GroupsService.update — Namenssperre und interner Name (D-03/D-04)', () => {
it('lehnt name fuer eine importierte Gruppe (gesetzter ldapObjectGuid) mit BadRequestException ab', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'Vertrieb' });
await (prisma as any).group.update({
where: { id: group.id },
data: { ldapObjectGuid: 'abc123' },
});
await expect(
service.update('t1', group.id, { name: 'Umbenannt' }),
).rejects.toBeInstanceOf(BadRequestException);
const list = await service.listForTenant('t1');
expect(list.find((g) => g.id === group.id)!.name).toBe('Vertrieb');
});
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();
it('erlaubt name fuer eine Gruppe ohne gesetzten ldapObjectGuid — unveraendertes Verhalten', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'Alt' });
const result = await service.update('t1', group.id, { name: 'Neu' });
expect(result.name).toBe('Neu');
});
it('setzt internalName auf einer importierten Gruppe, name bleibt unangetastet', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'AD-Name' });
await (prisma as any).group.update({
where: { id: group.id },
data: { ldapObjectGuid: 'abc123' },
});
const result = await service.update('t1', group.id, { internalName: 'Vertrieb' });
expect(result.internalName).toBe('Vertrieb');
expect(result.name).toBe('AD-Name');
});
it('normalisiert internalName aus reinen Leerzeichen auf null statt auf einen leeren Anzeigenamen', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
await service.update('t1', group.id, { internalName: 'Vertrieb' });
const result = await service.update('t1', group.id, { internalName: ' ' });
expect(result.internalName).toBeNull();
});
it('setzt internalName:null direkt auf null', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
await service.update('t1', group.id, { internalName: 'Vertrieb' });
const result = await service.update('t1', group.id, { internalName: null });
expect(result.internalName).toBeNull();
});
it('ein zweites PATCH mit identischem internalName aendert nichts und wirft nicht', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
await service.update('t1', group.id, { internalName: 'Vertrieb' });
const result = await service.update('t1', group.id, { internalName: 'Vertrieb' });
expect(result.internalName).toBe('Vertrieb');
});
it('erlaubt internalName auf einer lokalen Gruppe (keine Sperre auf diesem Feld)', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'Lokal' });
const result = await service.update('t1', group.id, { internalName: 'Anzeigename' });
expect(result.internalName).toBe('Anzeigename');
});
it('speichert und liefert Unicode-Namen unveraendert, ueber den getrimmten Inhalt entschieden statt ueber Truthiness', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
const result = await service.update('t1', group.id, { internalName: ' Vértriéb 部門 ' });
expect(result.internalName).toBe('Vértriéb 部門');
});
it('listForTenant() liefert internalName zusaetzlich zu name', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'AD-Name' });
await service.update('t1', group.id, { internalName: 'Vertrieb' });
const list = await service.listForTenant('t1');
expect(list.find((g) => g.id === group.id)).toMatchObject({
name: 'AD-Name',
internalName: 'Vertrieb',
});
});
});
// --- getImpact ---------------------------------------------------------