feat(quick-260910-exd): ModuleAccessService an forTenant() binden
- module-access.service.ts: getAccessibleModuleIds (Kurzschlusszweig,
Direktweg, Gruppenweg, Schnittmenge) und getCatalogFlags' eigener
Aktivierungs-Lesezugriff laufen ueber forTenant(), EIN Klient je Methode
unter dem Namen tenantPrisma; der Katalogzugriff in findAccessibleModules
bleibt bewusst ungebunden (Modulkatalog traegt keine Regel), mit Kommentar
der Messung und Bedingung trennt
- Bestehende where-Filter mit tenantId bleiben als zweites Netz stehen
- module-access.service.spec.ts: Zwei-Klienten-Nachweis ueber
__makeBoundClient (Muster aus module-grants.service.spec.ts), alle 15
bestehenden Faelle erhalten, neue Faelle fuer jede in <behavior> genannte
Bindungseigenschaft inkl. Wachhund gegen eine kuenftige Katalogbindung
- module.guard.spec.ts: ein Fall, der die Abwesenheit eines
unterscheidenden Signals fuer "keine Freigabe" vs. "Abfrage fand nichts"
festnagelt
- Falsifizierungsnachweis durchgefuehrt: Gruppenweg-Bindung probeweise
zurueckgebaut, Test "USER-Zweig bindet BEIDE Freigabe-Lesezugriffe..."
wurde rot ("expected 1 to be 2"), Ruecknahme bestaetigt wieder gruen
- mandantentrennung-zugriffsklassifikation.md: Stand fuer
module-access.service.ts/moduleGrant und /tenantModuleActivation auf
gebunden nachgezogen (Rule 3 — sonst waere rls-access-inventory.spec.ts
rot geblieben); die uebrigen vier Bestandsaufnahme-Stellen bleiben
Aufgabe 3 vorbehalten
- 817 Tests gruen (55 Dateien), Typpruefung sauber, Wegwerf-Werkzeug 66/66
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -6,9 +6,27 @@ import { ModuleAccessService } from './module-access.service';
|
|||||||
* Modulzugriff (D-01, PERM-04/05/06). Deckt die vollständige Behavior-
|
* Modulzugriff (D-01, PERM-04/05/06). Deckt die vollständige Behavior-
|
||||||
* Liste aus 15-01-PLAN.md, Task 2 ab.
|
* Liste aus 15-01-PLAN.md, Task 2 ab.
|
||||||
*
|
*
|
||||||
* Hand-gerollter Prisma-Mock (Projektkonvention, siehe
|
* Bindung an forTenant() (260910-exd, Aufgabe 2, Befund C uebertragen von
|
||||||
* tender-matching.service.spec.ts) statt einer echten DB-Verbindung.
|
* `module-grants.service.spec.ts`, dem bereits umgestellten Nachbarn auf
|
||||||
|
* denselben Modellen `tenantModuleActivation`/`moduleGrant`): der gebundene
|
||||||
|
* Klient ist ein ZWEITES, von `prisma` unterscheidbares Objekt ueber
|
||||||
|
* DEMSELBEN Speicher, das protokolliert, welche Aufrufe ueber ihn liefen
|
||||||
|
* (Modellname, Methodenname, Mandantenkennung). Ein reiner Identitaets-Mock
|
||||||
|
* (`forTenant: vi.fn((p) => p)`) koennte einen vergessenen Bindungsaufruf
|
||||||
|
* nicht von einem ungebundenen Aufruf unterscheiden.
|
||||||
|
*
|
||||||
|
* `module` wird NICHT gewrappt — der Katalogzugriff laeuft bewusst ueber
|
||||||
|
* den ungebundenen Klienten (Befund E, Aufgabe 1): die Tabelle traegt heute
|
||||||
|
* keinen Zeilenschutz, eine Bindung waere heute wirkungslos.
|
||||||
*/
|
*/
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
|
||||||
|
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
|
||||||
|
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. */
|
||||||
|
const BOUND_MODEL_NAMES = ['tenantModuleActivation', 'moduleGrant'];
|
||||||
|
|
||||||
function makeFakePrisma(opts: {
|
function makeFakePrisma(opts: {
|
||||||
activations?: { moduleId: string }[];
|
activations?: { moduleId: string }[];
|
||||||
@@ -18,8 +36,9 @@ function makeFakePrisma(opts: {
|
|||||||
const activations = opts.activations ?? [];
|
const activations = opts.activations ?? [];
|
||||||
const directGrants = opts.directGrants ?? [];
|
const directGrants = opts.directGrants ?? [];
|
||||||
const groupGrants = opts.groupGrants ?? [];
|
const groupGrants = opts.groupGrants ?? [];
|
||||||
|
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||||
|
|
||||||
const prisma = {
|
const fake: any = {
|
||||||
tenantModuleActivation: {
|
tenantModuleActivation: {
|
||||||
findMany: vi.fn(async ({ where }: any) => {
|
findMany: vi.fn(async ({ where }: any) => {
|
||||||
// filtert die simulierten Aktivierungen zusätzlich auf moduleId,
|
// filtert die simulierten Aktivierungen zusätzlich auf moduleId,
|
||||||
@@ -43,9 +62,55 @@ function makeFakePrisma(opts: {
|
|||||||
return ids.map((id) => ({ id, name: id }));
|
return ids.map((id) => ({ id, name: id }));
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// --- Bindungsnachweis (260910-exd, Befund C uebertragen) ---------------
|
||||||
|
__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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return prisma;
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bindungsnachweis: mindestens ein Aufruf von `<tenantId>.<model>.<method>`
|
||||||
|
* lief ueber den gebundenen Client (nicht ueber den rohen, ungebundenen
|
||||||
|
* Fake). Ein vergessener `forTenant()`-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wachhund-Gegenprobe (Aufgabe 2): ein Modell darf im Bindungsprotokoll gar
|
||||||
|
* nicht vorkommen — das ist der Testfall, der jemanden erwischt, der den
|
||||||
|
* bewusst ungebundenen Katalogzugriff spaeter versehentlich bindet.
|
||||||
|
*/
|
||||||
|
function expectNeverBound(prisma: any, model: string) {
|
||||||
|
const found = prisma.__boundCallLog.some((c: any) => c.model === model);
|
||||||
|
expect(
|
||||||
|
found,
|
||||||
|
`Modell "${model}" darf nie im Bindungsprotokoll auftauchen (Katalog bleibt ungebunden): ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||||
|
).toBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ModuleAccessService.getAccessibleModuleIds — ADMIN/SUPER_ADMIN-Kurzschluss (D-03)', () => {
|
describe('ModuleAccessService.getAccessibleModuleIds — ADMIN/SUPER_ADMIN-Kurzschluss (D-03)', () => {
|
||||||
@@ -88,13 +153,8 @@ describe('ModuleAccessService.getAccessibleModuleIds — ADMIN/SUPER_ADMIN-Kurzs
|
|||||||
t1: [{ moduleId: 'mod-tenant-1' }],
|
t1: [{ moduleId: 'mod-tenant-1' }],
|
||||||
t2: [{ moduleId: 'mod-tenant-2' }],
|
t2: [{ moduleId: 'mod-tenant-2' }],
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = makeFakePrisma();
|
||||||
tenantModuleActivation: {
|
prisma.tenantModuleActivation.findMany = vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []);
|
||||||
findMany: vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []),
|
|
||||||
},
|
|
||||||
moduleGrant: { findMany: vi.fn() },
|
|
||||||
module: { findMany: vi.fn() },
|
|
||||||
};
|
|
||||||
const service = new ModuleAccessService(prisma as any);
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
const resultT2 = await service.getAccessibleModuleIds('t2', 'admin-1', 'ADMIN');
|
const resultT2 = await service.getAccessibleModuleIds('t2', 'admin-1', 'ADMIN');
|
||||||
@@ -260,3 +320,101 @@ describe('ModuleAccessService.getCatalogFlags — Marketplace-Katalog (D-08)', (
|
|||||||
expect(flags.get('mod-1')).toEqual({ isActiveForTenant: true, hasAccess: true });
|
expect(flags.get('mod-1')).toEqual({ isActiveForTenant: true, hasAccess: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Bindung an forTenant() (260910-exd, Aufgabe 2) -------------------------
|
||||||
|
|
||||||
|
describe('ModuleAccessService — Bindung an forTenant() (260910-exd)', () => {
|
||||||
|
it('Kurzschlusszweig (ADMIN) bindet seinen Aktivierungs-Lesezugriff an die Mandantenkennung aus dem Sitzungsnachweis', async () => {
|
||||||
|
const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }] });
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN');
|
||||||
|
|
||||||
|
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('USER-Zweig bindet BEIDE Freigabe-Lesezugriffe (Direktweg und Gruppenweg) UND den Schnittmengen-Lesezugriff an DIESELBE Mandantenkennung', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
activations: [{ moduleId: 'mod-1' }],
|
||||||
|
directGrants: [{ moduleId: 'mod-1' }],
|
||||||
|
groupGrants: [{ moduleId: 'mod-1' }],
|
||||||
|
});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
const grantCalls = prisma.__boundCallLog.filter(
|
||||||
|
(c: any) => c.model === 'moduleGrant' && c.method === 'findMany',
|
||||||
|
);
|
||||||
|
expect(grantCalls.length).toBe(2);
|
||||||
|
expect(grantCalls.every((c: any) => c.tenantId === 't1')).toBe(true);
|
||||||
|
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Vorgabezustand bleibt geschlossen und ueberlebt die Bindung: ohne Grants leeres Set, der Schnittmengen-Lesezugriff wird gar nicht erst ausgefuehrt', async () => {
|
||||||
|
const prisma = makeFakePrisma({});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
expect(result).toEqual(new Set());
|
||||||
|
const activationCalls = prisma.__boundCallLog.filter(
|
||||||
|
(c: any) => c.model === 'tenantModuleActivation' && c.method === 'findMany',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
activationCalls,
|
||||||
|
`der Schnittmengen-Lesezugriff auf tenantModuleActivation darf ohne Grants nicht stattfinden, gefunden: ${JSON.stringify(activationCalls)}`,
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Rollen-Kurzschluss waechst durch die Bindung nicht: eine Aufloesung fuer einen zweiten Mandanten leitet keine Module des ersten ab, die gebundene Kennung im Protokoll ist die des zweiten', async () => {
|
||||||
|
const activationsByTenant: Record<string, { moduleId: string }[]> = {
|
||||||
|
t1: [{ moduleId: 'mod-tenant-1' }],
|
||||||
|
t2: [{ moduleId: 'mod-tenant-2' }],
|
||||||
|
};
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
prisma.tenantModuleActivation.findMany = vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []);
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const resultT2 = await service.getAccessibleModuleIds('t2', 'admin-2', 'ADMIN');
|
||||||
|
|
||||||
|
expect(resultT2).toEqual(new Set(['mod-tenant-2']));
|
||||||
|
expectBoundCall(prisma, 't2', 'tenantModuleActivation', 'findMany');
|
||||||
|
const t1Calls = prisma.__boundCallLog.filter((c: any) => c.tenantId === 't1');
|
||||||
|
expect(t1Calls, `keine Bindung an t1 erwartet: ${JSON.stringify(t1Calls)}`).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findAccessibleModules erreicht den Katalog ueber den UNGEBUNDENEN Klienten — der Katalogzugriff taucht im Bindungsprotokoll nicht auf', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
activations: [{ moduleId: 'mod-1' }],
|
||||||
|
directGrants: [{ moduleId: 'mod-1' }],
|
||||||
|
});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
await service.findAccessibleModules('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
expectNeverBound(prisma, 'module');
|
||||||
|
expect(prisma.module.findMany).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCatalogFlags bindet seinen eigenen Aktivierungs-Lesezugriff, und die geschachtelte Aufloesung erzeugt ihren eigenen gebundenen Klienten mit derselben Mandantenkennung', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
activations: [{ moduleId: 'mod-1' }],
|
||||||
|
directGrants: [{ moduleId: 'mod-1' }],
|
||||||
|
});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
await service.getCatalogFlags('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
const activationCalls = prisma.__boundCallLog.filter(
|
||||||
|
(c: any) => c.model === 'tenantModuleActivation' && c.method === 'findMany',
|
||||||
|
);
|
||||||
|
// Ein Aufruf fuer getCatalogFlags selbst, ein zweiter aus der
|
||||||
|
// geschachtelten getAccessibleModuleIds-Aufloesung — beide gebunden an
|
||||||
|
// denselben Mandanten, aber ueber je einen eigenen forTenant()-Aufruf
|
||||||
|
// (dieselbe Konvention wie module-grants.service.ts).
|
||||||
|
expect(activationCalls.length).toBe(2);
|
||||||
|
expect(activationCalls.every((c: any) => c.tenantId === 't1')).toBe(true);
|
||||||
|
expectNeverBound(prisma, 'module');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single Source of Truth für Modulzugriff (D-01, PERM-04/05/06).
|
* Single Source of Truth für Modulzugriff (D-01, PERM-04/05/06).
|
||||||
@@ -39,31 +40,41 @@ export class ModuleAccessService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
role: Role,
|
role: Role,
|
||||||
): Promise<Set<string>> {
|
): Promise<Set<string>> {
|
||||||
|
// EIN gebundener Klient fuer alle vier mandantengebundenen Zugriffe
|
||||||
|
// dieser Methode (Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge)
|
||||||
|
// — nicht ein Klient je Modellzugriff (260910-exd, Aufgabe 2). Die
|
||||||
|
// bestehenden `where`-Filter mit tenantId bleiben ZUSAETZLICH stehen:
|
||||||
|
// sie sind das zweite Netz, nicht redundant — dieselbe Begruendung wie
|
||||||
|
// in `module-grants.service.ts` (die Regel auf `GroupMembership` prueft
|
||||||
|
// nachweislich nur die Gruppenseite, T-JTS-02, und die Regel auf
|
||||||
|
// `ModuleGrant` nur die Mandantenkennung der Zeile, T-JTS-03).
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
|
||||||
if (role === 'ADMIN' || role === 'SUPER_ADMIN') {
|
if (role === 'ADMIN' || role === 'SUPER_ADMIN') {
|
||||||
const activations = await this.prisma.tenantModuleActivation.findMany({
|
const activations = await tenantPrisma.tenantModuleActivation.findMany({
|
||||||
where: { tenantId, isActive: true },
|
where: { tenantId, isActive: true },
|
||||||
select: { moduleId: true },
|
select: { moduleId: true },
|
||||||
});
|
});
|
||||||
return new Set(activations.map((a) => a.moduleId));
|
return new Set(activations.map((a: { moduleId: string }) => a.moduleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [direct, viaGroup] = await Promise.all([
|
const [direct, viaGroup] = await Promise.all([
|
||||||
this.prisma.moduleGrant.findMany({
|
tenantPrisma.moduleGrant.findMany({
|
||||||
where: { tenantId, userId },
|
where: { tenantId, userId },
|
||||||
select: { moduleId: true },
|
select: { moduleId: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.moduleGrant.findMany({
|
tenantPrisma.moduleGrant.findMany({
|
||||||
where: { tenantId, group: { memberships: { some: { userId } } } },
|
where: { tenantId, group: { memberships: { some: { userId } } } },
|
||||||
select: { moduleId: true },
|
select: { moduleId: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const grantedIds = [...direct, ...viaGroup].map((g) => g.moduleId);
|
const grantedIds = [...direct, ...viaGroup].map((g: { moduleId: string }) => g.moduleId);
|
||||||
|
|
||||||
if (grantedIds.length === 0) {
|
if (grantedIds.length === 0) {
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
const activations = await this.prisma.tenantModuleActivation.findMany({
|
const activations = await tenantPrisma.tenantModuleActivation.findMany({
|
||||||
where: {
|
where: {
|
||||||
tenantId,
|
tenantId,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -71,7 +82,7 @@ export class ModuleAccessService {
|
|||||||
},
|
},
|
||||||
select: { moduleId: true },
|
select: { moduleId: true },
|
||||||
});
|
});
|
||||||
return new Set(activations.map((a) => a.moduleId));
|
return new Set(activations.map((a: { moduleId: string }) => a.moduleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,6 +98,13 @@ export class ModuleAccessService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Der Katalogzugriff laeuft bewusst ueber den ungebundenen Klienten
|
||||||
|
// (260910-exd, Aufgabe 1, Befund E): die Tabelle "Module" traegt heute
|
||||||
|
// keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht
|
||||||
|
// katastrophal. Katastrophal wuerde sie erst, WENN Etappe 3 dieser
|
||||||
|
// Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer
|
||||||
|
// jeden Mandanten. Diese Bedingung steht hier als Bedingung, nicht als
|
||||||
|
// heute beobachtbare Tatsache.
|
||||||
return this.prisma.module.findMany({
|
return this.prisma.module.findMany({
|
||||||
where: { id: { in: [...accessibleIds] } },
|
where: { id: { in: [...accessibleIds] } },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
@@ -113,8 +131,14 @@ export class ModuleAccessService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
role: Role,
|
role: Role,
|
||||||
): Promise<Map<string, { isActiveForTenant: boolean; hasAccess: boolean }>> {
|
): Promise<Map<string, { isActiveForTenant: boolean; hasAccess: boolean }>> {
|
||||||
|
// Eigener gebundener Klient fuer den Aktivierungs-Lesezugriff dieser
|
||||||
|
// Methode — die geschachtelte getAccessibleModuleIds()-Aufloesung
|
||||||
|
// erzeugt ihren EIGENEN Klienten (dieselbe Konvention wie
|
||||||
|
// `module-grants.service.ts`: gebundene Klienten werden nicht zwischen
|
||||||
|
// Methoden weitergereicht). Beide laufen wie bisher nebenlaeufig.
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
const [activations, accessibleIds] = await Promise.all([
|
const [activations, accessibleIds] = await Promise.all([
|
||||||
this.prisma.tenantModuleActivation.findMany({
|
tenantPrisma.tenantModuleActivation.findMany({
|
||||||
where: { tenantId, isActive: true },
|
where: { tenantId, isActive: true },
|
||||||
select: { moduleId: true },
|
select: { moduleId: true },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -116,4 +116,70 @@ describe('ModuleGuard.canActivate', () => {
|
|||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect((request as any).moduleAccessIds).toBe(accessibleIds);
|
expect((request as any).moduleAccessIds).toBe(accessibleIds);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 260910-exd, Aufgabe 2 — nagelt die ABWESENHEIT eines unterscheidenden
|
||||||
|
* Signals fest: es gibt heute KEIN Signal, das "wirklich keine Freigabe"
|
||||||
|
* (USER hat tatsächlich keinen Grant) von "die Auflösung hat nichts
|
||||||
|
* gefunden" (z. B. eine nach dem Scharfschalten ungebunden gebliebene
|
||||||
|
* Abfrage) unterscheidet. Beide Aufrufe liefern eine leere Menge und
|
||||||
|
* damit DIESELBE ForbiddenException mit DERSELBEN Meldung — dieser Test
|
||||||
|
* hält die beiden Meldungen gegeneinander.
|
||||||
|
*
|
||||||
|
* DIESER TEST SOLL ROT WERDEN, sobald jemand ein unterscheidendes Signal
|
||||||
|
* einbaut (z. B. ein eigener Fehlercode oder eine unterschiedliche
|
||||||
|
* Meldung für "leer, weil keine Freigabe" vs. "leer, weil die Abfrage
|
||||||
|
* nichts gefunden hat"). Wird er rot, ist das kein Bug in diesem Test,
|
||||||
|
* sondern der Beleg, dass die Lücke geschlossen wurde — dann sind sowohl
|
||||||
|
* dieser Test als auch Abschnitt (m3) von
|
||||||
|
* docs/mandantentrennung-etappe2-fehlerrichtung.md nachzuziehen.
|
||||||
|
*/
|
||||||
|
it('liefert fuer "wirklich keine Freigabe" und fuer "die Aufloesung hat nichts gefunden" dieselbe Ausnahme mit derselben Meldung (kein unterscheidendes Signal)', async () => {
|
||||||
|
const moduleRegistryService = {
|
||||||
|
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Fall A: der Benutzer hat tatsächlich keine Freigabe.
|
||||||
|
const moduleAccessServiceGenuinelyEmpty = {
|
||||||
|
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()),
|
||||||
|
} as any;
|
||||||
|
const guardA = new ModuleGuard(
|
||||||
|
makeReflector('domaincheck'),
|
||||||
|
moduleRegistryService,
|
||||||
|
moduleAccessServiceGenuinelyEmpty,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fall B: es gibt Freigaben, aber die Auflösung liefert (z. B. wegen
|
||||||
|
// einer ungebunden gebliebenen Abfrage) trotzdem eine leere Menge —
|
||||||
|
// aus Sicht des Wächters nicht von Fall A zu unterscheiden.
|
||||||
|
const moduleAccessServiceQueryFoundNothing = {
|
||||||
|
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()),
|
||||||
|
} as any;
|
||||||
|
const guardB = new ModuleGuard(
|
||||||
|
makeReflector('domaincheck'),
|
||||||
|
moduleRegistryService,
|
||||||
|
moduleAccessServiceQueryFoundNothing,
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = { tenantId: 't1', user: { id: 'user-1', role: 'USER' } };
|
||||||
|
|
||||||
|
let messageA = '';
|
||||||
|
let messageB = '';
|
||||||
|
try {
|
||||||
|
await guardA.canActivate(makeContext(request));
|
||||||
|
} catch (err) {
|
||||||
|
messageA = (err as ForbiddenException).message;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await guardB.canActivate(makeContext(request));
|
||||||
|
} catch (err) {
|
||||||
|
messageB = (err as ForbiddenException).message;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messageA).not.toBe('');
|
||||||
|
expect(
|
||||||
|
messageB,
|
||||||
|
'heute gibt es kein Signal, das diese beiden Faelle unterscheidet — beide Meldungen muessen identisch sein',
|
||||||
|
).toBe(messageA);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -289,8 +289,8 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
|||||||
| apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | gebunden | Die `lastSyncAt`-Fortschreibung am Ende von `syncUsersForTenant` ist mit Aufgabe 3 (260909-ipc) auf den in derselben Methode bereits vorhandenen `forTenant()`-Client umgestellt — es entsteht kein zweiter. Damit ist der einzige `ldapConfig`-Zugriff dieser Datei gebunden. |
|
| apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | gebunden | Die `lastSyncAt`-Fortschreibung am Ende von `syncUsersForTenant` ist mit Aufgabe 3 (260909-ipc) auf den in derselben Methode bereits vorhandenen `forTenant()`-Client umgestellt — es entsteht kein zweiter. Damit ist der einzige `ldapConfig`-Zugriff dieser Datei gebunden. |
|
||||||
| apps/api/src/ldap/ldap.service.ts | user | beides | gemischt | Mit Aufgabe 3 (260909-ipc) sind `upsertMappedUser` (Identitaetssuche und Aktualisierung), `searchUsers` (die "bereits importiert"-Markierung), `importUsersByDn` (Dedup und ldapDn-Nachtrag) und die Deaktivierungsschleife in `syncUsersForTenant` auf `forTenant()` umgestellt. `resolveEmailForWrite` bleibt ausdruecklich UNGEBUNDEN (Befund A, T-IPC-04): `email`/`username` sind plattformweit eindeutig, eine mandantengebundene Suche saehe einen fremden Halter nicht mehr und meldete faelschlich "frei" — die geloeste Klasse waere `muss-mandantengebunden` gewesen, bleibt wegen dieser einen bewusst uebergreifenden Abfrage `beides`. Der Loeschzweig um `syncBoundGroupsForTenant` (WINDOWS #20) ist bereits seit Etappe 1 gebunden und war nie Teil dieses Befunds. |
|
| apps/api/src/ldap/ldap.service.ts | user | beides | gemischt | Mit Aufgabe 3 (260909-ipc) sind `upsertMappedUser` (Identitaetssuche und Aktualisierung), `searchUsers` (die "bereits importiert"-Markierung), `importUsersByDn` (Dedup und ldapDn-Nachtrag) und die Deaktivierungsschleife in `syncUsersForTenant` auf `forTenant()` umgestellt. `resolveEmailForWrite` bleibt ausdruecklich UNGEBUNDEN (Befund A, T-IPC-04): `email`/`username` sind plattformweit eindeutig, eine mandantengebundene Suche saehe einen fremden Halter nicht mehr und meldete faelschlich "frei" — die geloeste Klasse waere `muss-mandantengebunden` gewesen, bleibt wegen dieser einen bewusst uebergreifenden Abfrage `beides`. Der Loeschzweig um `syncBoundGroupsForTenant` (WINDOWS #20) ist bereits seit Etappe 1 gebunden und war nie Teil dieses Befunds. |
|
||||||
| apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId`. |
|
| apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId`. |
|
||||||
| apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant. |
|
| apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | gebunden | Modulfreigaben je Mandant. Seit 260910-exd (Aufgabe 2) laufen Direktweg und Gruppenweg von `getAccessibleModuleIds` ueber `forTenant()`, EIN Klient je Methode. |
|
||||||
| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. |
|
| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. Seit 260910-exd (Aufgabe 2) laufen Kurzschlusszweig, Schnittmengenabfrage und der eigene Lesezugriff von `getCatalogFlags` ueber `forTenant()`. |
|
||||||
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. |
|
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. |
|
||||||
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant. |
|
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant. |
|
||||||
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | ungebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. |
|
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | ungebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. |
|
||||||
|
|||||||
Reference in New Issue
Block a user