feat(jts-02): groups.service.ts binden, Transaktionen tragfaehig machen, Absicherung sehend machen
prisma-tenant.extension.ts bekommt withTenantTransaction(prisma, tenantId, fn) — die interaktive Transaktion auf dem UNgebundenen Client mit set_config als erster Anweisung direkt auf tx, die in Aufgabe 1 als einzige der drei gemessenen Formen sowohl die Einzelmessung als auch eine Lastprobe unter echter Nebenlaeufigkeit bestand (die Array-Form auf dem gebundenen Client verteilt jede Operation auf eine eigene Teiltransaktion; die interaktive Form auf dem gebundenen Client brach unter 40 parallelen Aufrufen mit P2028 ab). rls-access-inventory.spec.ts bekommt eine dritte Erkennung fuer Modellzugriffe ueber den Rueckgabeparameter einer interaktiven Transaktion (zwei Formen: direkter Empfaenger.$transaction(async...) und das neue Hilfsmittel withTenantTransaction(...)) — macht das Paar (groups.service.ts, tenantModuleActivation) erstmals sichtbar, das bislang keine Pruefung dieses Projekts je gesehen hat. groups.service.ts: alle zwoelf Methoden inklusive der drei Transaktionen (update() isDefault:true, reassignDefaultBeforeDelete(), ensureDefaultGroup()) laufen jetzt ueber den Mandantenkontext. Zaehler und Transaktion in ensureDefaultGroup() sind gemeinsam gebunden (T-JTS-05). addUserToDefaultGroup() prueft neu, dass der Zielbenutzer zum Mandanten gehoert (Befund E, T-JTS-02) — die Regel auf GroupMembership prueft nachweislich nur die Gruppenseite. groups.service.spec.ts bekommt zwei unterscheidbare Clients ueber demselben Speicher-Fake (Muster aus 260909-ipc, auf die interaktive Form uebertragen) und 13 neue Bindungsnachweise; alle 42 Bestandstests bleiben gruen. Klassifikationsdokument nachgezogen. 737 Tests und die Typpruefung gruen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { MembershipSource } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_GROUP_NAME, GroupsService } from './groups.service';
|
||||
|
||||
/**
|
||||
@@ -11,7 +11,28 @@ import { DEFAULT_GROUP_NAME, GroupsService } from './groups.service';
|
||||
* keine Live-DB, simuliert P2002 (Unique-Verletzung) und P2025
|
||||
* (Record-not-found bei einem zweiten remove()-Aufruf) exakt wie ein
|
||||
* echter Postgres-Client via Prisma-Fehlercodes.
|
||||
*
|
||||
* Bindung an forTenant()/withTenantTransaction() (260909-jts, Befund C):
|
||||
* anders als bei ldap-config.service.spec.ts (einfacher Identitaets-Mock
|
||||
* `forTenant: vi.fn((p) => p)`) braucht diese Datei einen Mock, der den
|
||||
* gebundenen Client als ZWEITES, von `prisma` UNTERSCHEIDBARES Objekt ueber
|
||||
* DEMSELBEN Speicher liefert — sonst waeren ein Aufruf ueber den
|
||||
* ungebundenen Fake und ein Aufruf ueber den (mit reiner Identitaet)
|
||||
* "gebundenen" Client nicht auseinanderzuhalten, und ein vergessener
|
||||
* Bindungsaufruf faellt in keinem Test auf. `makeFakePrisma()` bekommt dafuer
|
||||
* `__makeBoundClient(tenantId)` (liefert je Modell einen protokollierenden
|
||||
* Wrapper um dieselben Maps) und `__withTenantTransaction(tenantId, fn)`
|
||||
* (reicht denselben gebundenen Client als Transaktionsparameter durch,
|
||||
* Muster aus 260909-ipc uebertragen auf die interaktive Form dieses
|
||||
* Bereichs). `forTenant`/`withTenantTransaction` selbst werden gemockt,
|
||||
* damit der Fake nicht durch die echte `$extends`-Implementierung muss.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||
withTenantTransaction: vi.fn((prisma: any, tenantId: string, fn: (tx: any) => any) =>
|
||||
prisma.__withTenantTransaction(tenantId, fn),
|
||||
),
|
||||
}));
|
||||
|
||||
function makeFakePrisma() {
|
||||
const groups = new Map<string, any>();
|
||||
@@ -22,6 +43,7 @@ function makeFakePrisma() {
|
||||
let groupCounter = 0;
|
||||
let membershipCounter = 0;
|
||||
let grantCounter = 0;
|
||||
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||
|
||||
function findGroupByTenantAndName(tenantId: string, name: string, excludeId?: string) {
|
||||
return Array.from(groups.values()).find(
|
||||
@@ -240,6 +262,13 @@ function makeFakePrisma() {
|
||||
}
|
||||
return Array.from(users.values()).filter((u) => u.tenantId === where.tenantId);
|
||||
},
|
||||
findFirst: async ({ where }: any) => {
|
||||
return (
|
||||
Array.from(users.values()).find(
|
||||
(u) => u.id === where.id && u.tenantId === where.tenantId,
|
||||
) ?? null
|
||||
);
|
||||
},
|
||||
},
|
||||
$transaction: async (opsOrFn: any) => {
|
||||
if (typeof opsOrFn === 'function') {
|
||||
@@ -247,11 +276,52 @@ function makeFakePrisma() {
|
||||
}
|
||||
return Promise.all(opsOrFn);
|
||||
},
|
||||
// --- Bindungsnachweis (260909-jts, Befund C) ---------------------------
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string) {
|
||||
const bound: any = { __isBoundClient: true, __tenantId: tenantId };
|
||||
for (const modelName of BOUND_MODEL_NAMES) {
|
||||
const model = fake[modelName];
|
||||
const wrapped: any = {};
|
||||
for (const method of Object.keys(model)) {
|
||||
wrapped[method] = async (...args: any[]) => {
|
||||
boundCallLog.push({ tenantId, model: modelName, method });
|
||||
return model[method](...args);
|
||||
};
|
||||
}
|
||||
bound[modelName] = wrapped;
|
||||
}
|
||||
return bound;
|
||||
},
|
||||
__withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
|
||||
boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
|
||||
return fn(fake.__makeBoundClient(tenantId));
|
||||
},
|
||||
};
|
||||
|
||||
return fake;
|
||||
}
|
||||
|
||||
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
|
||||
* Herkunfts-Tenant protokollierenden Wrapper versieht. */
|
||||
const BOUND_MODEL_NAMES = ['group', 'groupMembership', 'moduleGrant', 'tenantModuleActivation', 'user'];
|
||||
|
||||
/**
|
||||
* Bindungsnachweis: mindestens ein Aufruf von `<tenantId>.<model>.<method>`
|
||||
* lief ueber den gebundenen Client (nicht ueber den rohen, ungebundenen
|
||||
* Fake). Ein vergessener `forTenant()`/`withTenantTransaction()`-Aufruf
|
||||
* hinterlaesst hier KEINEN Eintrag und laesst den Test fehlschlagen.
|
||||
*/
|
||||
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
|
||||
const found = prisma.__boundCallLog.some(
|
||||
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
|
||||
);
|
||||
expect(
|
||||
found,
|
||||
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
describe('GroupsService', () => {
|
||||
// --- listForTenant ------------------------------------------------------
|
||||
|
||||
@@ -617,6 +687,10 @@ describe('GroupsService', () => {
|
||||
|
||||
const group = await service.create('t1', { name: 'Alle Benutzer' });
|
||||
await service.update('t1', group.id, { isDefault: true });
|
||||
// Befund E/T-JTS-02 (260909-jts): die Methode prueft seither, dass der
|
||||
// Zielbenutzer zum Mandanten gehoert — ohne einen seed hier waere
|
||||
// dieser Test wieder die Luecke, die er einst unbemerkt durchliess.
|
||||
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
||||
|
||||
await service.addUserToDefaultGroup('t1', 'u1');
|
||||
|
||||
@@ -847,3 +921,164 @@ describe('GroupsService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Bindung an forTenant()/withTenantTransaction() (260909-jts, Aufgabe 2) ---
|
||||
|
||||
describe('GroupsService — Bindung an forTenant()/withTenantTransaction() (260909-jts)', () => {
|
||||
it('listForTenant() bindet group.findMany an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
|
||||
await service.listForTenant('t1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findMany');
|
||||
});
|
||||
|
||||
it('create() bindet group.create an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
|
||||
await service.create('t1', { name: 'A' });
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'create');
|
||||
});
|
||||
|
||||
it('update() (Name/internalName, kein isDefault) bindet findOwned und group.update an den uebergebenen Mandanten', 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: 'X' });
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'group', 'update');
|
||||
});
|
||||
|
||||
it('update() mit isDefault:true laeuft als EINE withTenantTransaction — beide Teilschritte (updateMany, update) landen am gebundenen Client', 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, { isDefault: true });
|
||||
|
||||
expectBoundCall(prisma, 't1', '$transaction', 'withTenantTransaction');
|
||||
expectBoundCall(prisma, 't1', 'group', 'updateMany');
|
||||
expectBoundCall(prisma, 't1', 'group', 'update');
|
||||
});
|
||||
|
||||
it('getImpact() bindet findOwned, groupMembership.count und moduleGrant.count an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'A' });
|
||||
|
||||
await service.getImpact('t1', group.id);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'count');
|
||||
expectBoundCall(prisma, 't1', 'moduleGrant', 'count');
|
||||
});
|
||||
|
||||
it('remove() bindet findOwned und group.delete an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'A' });
|
||||
|
||||
await service.remove('t1', group.id);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'group', 'delete');
|
||||
});
|
||||
|
||||
it('listMembers() bindet findOwned und groupMembership.findMany an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'A' });
|
||||
|
||||
await service.listMembers('t1', group.id);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'findMany');
|
||||
});
|
||||
|
||||
it('addMembers() bindet findOwned, user.findMany und groupMembership.createMany an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'A' });
|
||||
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
||||
|
||||
await service.addMembers('t1', group.id, ['u1']);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'user', 'findMany');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'createMany');
|
||||
});
|
||||
|
||||
it('removeMember() bindet findOwned und groupMembership.deleteMany an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'A' });
|
||||
|
||||
await service.removeMember('t1', group.id, 'u1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'deleteMany');
|
||||
});
|
||||
|
||||
it('ensureDefaultGroup() bindet den Zaehler UND alle vier Schritte der Transaktion an denselben Mandanten (T-JTS-05)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
||||
prisma.__seedActivation({ id: 'a1', tenantId: 't1', moduleId: 'mod-a', isActive: true });
|
||||
|
||||
await service.ensureDefaultGroup('t1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'count');
|
||||
expectBoundCall(prisma, 't1', '$transaction', 'withTenantTransaction');
|
||||
expectBoundCall(prisma, 't1', 'group', 'create');
|
||||
expectBoundCall(prisma, 't1', 'user', 'findMany');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'createMany');
|
||||
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
|
||||
expectBoundCall(prisma, 't1', 'moduleGrant', 'createMany');
|
||||
});
|
||||
|
||||
it('reassignDefaultBeforeDelete() bindet die drei Lesezugriffe UND die Transaktion an denselben Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const def = await service.create('t1', { name: DEFAULT_GROUP_NAME });
|
||||
const toDelete = await service.create('t1', { name: 'Zu loeschen' });
|
||||
await service.update('t1', toDelete.id, { isDefault: true });
|
||||
|
||||
await service.reassignDefaultBeforeDelete('t1', toDelete.id);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', '$transaction', 'withTenantTransaction');
|
||||
expectBoundCall(prisma, 't1', 'group', 'updateMany');
|
||||
expectBoundCall(prisma, 't1', 'group', 'update');
|
||||
});
|
||||
|
||||
it('addUserToDefaultGroup() bindet group.findFirst und groupMembership.createMany an den uebergebenen Mandanten', 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 });
|
||||
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
||||
|
||||
await service.addUserToDefaultGroup('t1', 'u1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'createMany');
|
||||
});
|
||||
|
||||
it('addUserToDefaultGroup() mit einem Zielbenutzer eines fremden Mandanten legt KEINE Mitgliedschaft an und wirft nicht (Befund E, T-JTS-02)', 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 });
|
||||
prisma.__seedUser({ id: 'u-fremd', tenantId: 't2' });
|
||||
|
||||
await expect(service.addUserToDefaultGroup('t1', 'u-fremd')).resolves.not.toThrow();
|
||||
|
||||
const members = await service.listMembers('t1', group.id);
|
||||
expect(members).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user