feat(260805-fok): GroupsService.ensureDefaultGroup(tenantId)

- Neue Methode ensureDefaultGroup: legt fuer einen Mandanten ohne jede
  Gruppe die Standardgruppe 'Alle Benutzer' (isDefault:true) an, nimmt
  alle Bestandsbenutzer als MANUAL-Mitglieder auf und erzeugt Grants
  fuer alle aktiven Module — derselbe Endzustand wie die drei
  Backfill-INSERTs der Migration 20260804130130
- Waechter prueft ausschliesslich group.count === 0, niemals die
  fehlende isDefault-Markierung (D-13)
- P2002 aus dem partiellen Index Group_one_default_per_tenant wird
  abgefangen und liefert null statt zu werfen (Race-Sicherheit)
- groups.service.spec.ts: Fake erweitert um group.count,
  tenantModuleActivation, moduleGrant.findMany/createMany,
  $transaction mit Callback-Form, plus voller ensureDefaultGroup-Testblock
This commit is contained in:
2026-08-05 11:28:16 +02:00
parent c2e9f67a7d
commit 9d1254cd78
2 changed files with 262 additions and 6 deletions
+185 -6
View File
@@ -18,8 +18,10 @@ function makeFakePrisma() {
const memberships = new Map<string, any>();
const grants = new Map<string, any>();
const users = new Map<string, any>();
const activations = new Map<string, any>();
let groupCounter = 0;
let membershipCounter = 0;
let grantCounter = 0;
function findGroupByTenantAndName(tenantId: string, name: string, excludeId?: string) {
return Array.from(groups.values()).find(
@@ -27,6 +29,10 @@ function makeFakePrisma() {
);
}
function findDefaultGroup(tenantId: string) {
return Array.from(groups.values()).find((g) => g.tenantId === tenantId && g.isDefault);
}
function throwUnique(): never {
const err: any = new Error('Unique constraint failed');
err.code = 'P2002';
@@ -39,14 +45,24 @@ function makeFakePrisma() {
throw err;
}
return {
const fake: any = {
__seedUser(user: { id: string; tenantId: string }) {
users.set(user.id, user);
},
__seedGrant(grant: { id: string; groupId: string }) {
grants.set(grant.id, grant);
},
__seedActivation(activation: {
id: string;
tenantId: string;
moduleId: string;
isActive: boolean;
}) {
activations.set(activation.id, activation);
},
group: {
count: async ({ where }: any) =>
Array.from(groups.values()).filter((g) => g.tenantId === where.tenantId).length,
findMany: async ({ where, include }: any) => {
let rows: any[] = Array.from(groups.values()).filter(
(g) => g.tenantId === where.tenantId,
@@ -76,6 +92,7 @@ function makeFakePrisma() {
},
create: async ({ data }: any) => {
if (findGroupByTenantAndName(data.tenantId, data.name)) throwUnique();
if (data.isDefault === true && findDefaultGroup(data.tenantId)) throwUnique();
groupCounter += 1;
const record = {
id: `g-${groupCounter}`,
@@ -158,20 +175,68 @@ function makeFakePrisma() {
count: async ({ where }: any) =>
Array.from(memberships.values()).filter((m) => m.groupId === where.groupId).length,
},
tenantModuleActivation: {
findMany: async ({ where }: any) =>
Array.from(activations.values()).filter(
(a) =>
a.tenantId === where.tenantId &&
(where.isActive === undefined || a.isActive === where.isActive),
),
},
moduleGrant: {
count: async ({ where }: any) =>
Array.from(grants.values()).filter((g) => g.groupId === where.groupId).length,
findMany: async ({ where }: any) =>
Array.from(grants.values()).filter(
(g) =>
(where?.tenantId === undefined || g.tenantId === where.tenantId) &&
(where?.groupId === undefined || g.groupId === where.groupId),
),
createMany: async ({ data, skipDuplicates }: any) => {
let count = 0;
for (const item of data) {
const exists = Array.from(grants.values()).find(
(g) =>
g.tenantId === item.tenantId &&
g.moduleId === item.moduleId &&
g.groupId === item.groupId,
);
if (exists) {
if (skipDuplicates) continue;
throwUnique();
}
grantCounter += 1;
grants.set(`grant-${grantCounter}`, {
id: `grant-${grantCounter}`,
createdAt: new Date(),
userId: null,
...item,
});
count += 1;
}
return { count };
},
},
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,
);
if (where?.id?.in) {
const ids: string[] = where.id.in;
return Array.from(users.values()).filter(
(u) => ids.includes(u.id) && u.tenantId === where.tenantId,
);
}
return Array.from(users.values()).filter((u) => u.tenantId === where.tenantId);
},
},
$transaction: async (ops: Promise<any>[]) => Promise.all(ops),
$transaction: async (opsOrFn: any) => {
if (typeof opsOrFn === 'function') {
return opsOrFn(fake);
}
return Promise.all(opsOrFn);
},
};
return fake;
}
describe('GroupsService', () => {
@@ -429,4 +494,118 @@ describe('GroupsService', () => {
await expect(service.addUserToDefaultGroup('t1', 'u1')).resolves.not.toThrow();
});
// --- ensureDefaultGroup ----------------------------------------------------
describe('ensureDefaultGroup()', () => {
it('legt bei einem Mandanten ohne jede Gruppe genau eine Gruppe "Alle Benutzer" mit isDefault:true an', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.ensureDefaultGroup('t-neu');
expect(group).not.toBeNull();
expect(group!.name).toBe('Alle Benutzer');
expect(group!.isDefault).toBe(true);
const list = await service.listForTenant('t-neu');
expect(list).toHaveLength(1);
});
it('nimmt alle Benutzer DIESES Mandanten als MANUAL-Mitglieder auf; ein Benutzer eines fremden Mandanten wird nicht Mitglied', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
prisma.__seedUser({ id: 'u2', tenantId: 't1' });
prisma.__seedUser({ id: 'u-fremd', tenantId: 't2' });
const group = await service.ensureDefaultGroup('t1');
const members = await service.listMembers('t1', group!.id);
expect(members.map((m: any) => m.userId).sort()).toEqual(['u1', 'u2']);
expect(members.every((m: any) => m.source === MembershipSource.MANUAL)).toBe(true);
});
it('erzeugt genau einen ModuleGrant je aktiver TenantModuleActivation; isActive:false und fremde Mandanten erzeugen keinen Grant', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
prisma.__seedActivation({ id: 'a1', tenantId: 't1', moduleId: 'mod-a', isActive: true });
prisma.__seedActivation({ id: 'a2', tenantId: 't1', moduleId: 'mod-b', isActive: false });
prisma.__seedActivation({ id: 'a3', tenantId: 't2', moduleId: 'mod-c', isActive: true });
const group = await service.ensureDefaultGroup('t1');
const grants = await (prisma as any).moduleGrant.findMany({ where: { tenantId: 't1' } });
expect(grants).toHaveLength(1);
expect(grants[0]).toMatchObject({
tenantId: 't1',
moduleId: 'mod-a',
groupId: group!.id,
userId: null,
});
});
it('Waechter: ein Mandant mit mindestens einer Gruppe ohne isDefault:true wird NICHT angefasst (D-13)', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'Ohne Markierung' });
const result = await service.ensureDefaultGroup('t1');
expect(result).toBeNull();
expect(await service.listForTenant('t1')).toHaveLength(1);
});
it('Waechter: ein Mandant mit bereits markierter Standardgruppe wird nicht angefasst', 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 });
const result = await service.ensureDefaultGroup('t1');
expect(result).toBeNull();
expect(await service.listForTenant('t1')).toHaveLength(1);
});
it('ist idempotent: zwei aufeinanderfolgende Aufrufe hinterlassen genau eine Gruppe und genau eine Mitgliedschaft pro Benutzer', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
const first = await service.ensureDefaultGroup('t1');
const second = await service.ensureDefaultGroup('t1');
expect(first).not.toBeNull();
expect(second).toBeNull();
const list = await service.listForTenant('t1');
expect(list).toHaveLength(1);
expect(list[0].memberCount).toBe(1);
});
it('faengt einen P2002 aus group.create ab (verlorenes Rennen gegen den partiellen Index) und liefert null statt zu werfen', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const existing = await service.create('t1', { name: 'Alle Benutzer' });
await service.update('t1', existing.id, { isDefault: true });
// group.count wird auf 0 gepatcht, um ein verlorenes Rennen nachzustellen —
// die schon markierte Gruppe existiert im Fake weiterhin und liefert beim
// Anlageversuch denselben P2002 wie der partielle Index in Postgres.
(prisma as any).group.count = async () => 0;
await expect(service.ensureDefaultGroup('t1')).resolves.toBeNull();
});
it('legt fuer einen Mandanten ganz ohne Benutzer und ohne aktive Module trotzdem die leere Standardgruppe an', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.ensureDefaultGroup('t-leer');
expect(group).not.toBeNull();
const members = await service.listMembers('t-leer', group!.id);
expect(members).toEqual([]);
const grants = await (prisma as any).moduleGrant.findMany({ where: { tenantId: 't-leer' } });
expect(grants).toEqual([]);
});
});
});
+77
View File
@@ -250,6 +250,83 @@ export class GroupsService {
});
}
/**
* Stellt für einen Mandanten OHNE JEDE Gruppe denselben Endzustand her,
* den die drei Backfill-INSERTs der Migration
* 20260804130130_add_groups_and_module_grants pro Mandant herstellen:
* eine Gruppe 'Alle Benutzer' (isDefault:true), alle Bestandsbenutzer als
* MANUAL-Mitglieder und Grants für alle aktiven Module. Aufrufer:
* TenantService.create (frischer Mandant) und
* AdminSeedService.ensureDefaultGroupsForAllTenants (Startup-Reparatur
* für Installationen, die die Migration ohne Mandanten durchlaufen haben).
*
* Der Wächter prüft AUSSCHLIESSLICH auf group.count === 0 — niemals auf
* das Fehlen der isDefault-Markierung. D-13 erlaubt dem Admin
* ausdrücklich, die Markierung abzuhängen oder auf eine andere Gruppe
* umzuhängen; ein Mandant mit mindestens einer Gruppe hat diese
* Entscheidung bereits getroffen und wird hier nie wieder angefasst.
* Rückgabe null bedeutet in jedem Fall "nichts zu tun".
*
* Race-Sicherheit: zwei gleichzeitige Aufrufe (z.B. Startup-Reparatur und
* eine parallele Mandanten-Anlage) können beide group.count === 0 lesen.
* Der partielle Unique-Index Group_one_default_per_tenant (15-01) bleibt
* der eigentliche Durchsetzungspunkt; hier wird nur der resultierende
* P2002 des Verlierers abgefangen und in null übersetzt, statt ihn zu
* propagieren.
*/
async ensureDefaultGroup(tenantId: string) {
const existingCount = await this.prisma.group.count({ where: { tenantId } });
if (existingCount > 0) {
return null;
}
try {
return await this.prisma.$transaction(async (tx) => {
const group = await tx.group.create({
data: { tenantId, name: 'Alle Benutzer', isDefault: true },
});
const users = await tx.user.findMany({
where: { tenantId },
select: { id: true },
});
if (users.length > 0) {
await tx.groupMembership.createMany({
data: users.map((u) => ({
groupId: group.id,
userId: u.id,
source: MembershipSource.MANUAL,
})),
skipDuplicates: true,
});
}
const activations = await tx.tenantModuleActivation.findMany({
where: { tenantId, isActive: true },
select: { moduleId: true },
});
if (activations.length > 0) {
await tx.moduleGrant.createMany({
data: activations.map((a) => ({
tenantId,
moduleId: a.moduleId,
groupId: group.id,
userId: null,
})),
skipDuplicates: true,
});
}
return group;
});
} catch (err: any) {
if (err?.code === 'P2002') {
return null;
}
throw err;
}
}
/**
* Legt eine Mitgliedschaft in der als Standard markierten Gruppe des
* Mandanten an (D-11/D-12/D-13). Existiert keine markierte Standardgruppe,