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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user