6b237351e9
- TenderRssFeedSourceService.listForUser() nimmt jetzt (userId, tenantId) entgegen und laeuft ueber einen gebundenen Klienten (forTenant) — die neue Leseregel schliesst plattformweite Zeilen ein, die Reparatur haette den ungebundenen Pfad sonst still auf nur die plattformweiten Zeilen reduziert (Befund F). createPlatform/remove bleiben bewusst ungebunden, Kommentare an der neuen Regel richtiggestellt. - TendersController.listRssFeeds reicht die Mandantenkennung aus dem Aufrufzusammenhang durch. - Vier Aufzeichnungen im Quelltext (module-access.service.ts, groups.service.ts, module-grants.service.ts, rls-coverage.spec.ts) sagen jetzt, dass die Datenbankregel seit 20260910120000_rls_widen_membership_ grant_and_platform_read beide Seiten prueft; die Anwendungspruefungen bleiben unveraendert bestehen (zweites Netz, wirkt vor dem Scharfschalten als einziger Schutz). - Zwei-Klienten-Nachweis in module-grants.service.spec.ts ergaenzt (Kommentar, warum die beiden Cross-Tenant-Tests nach der Regelaenderung nicht entfallen duerfen) und in tender-rss-feed.service.spec.ts umgekehrt (listForUser bindet jetzt). - Rule 1: implizites any beim Destrukturieren in listRssFeeds (feeds ist seit der Bindung `any`) mit expliziter Annotation behoben. - Falsifizierungsnachweis durchgefuehrt: Bindungsaufruf zurueckgenommen, genau ein Test wurde rot (AssertionError, 0 statt der erwarteten Aufrufe), Ruecknahme rueckgaengig gemacht. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
737 lines
28 KiB
TypeScript
737 lines
28 KiB
TypeScript
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { ModuleGrantsService } from './module-grants.service';
|
|
|
|
/**
|
|
* ModuleGrantsService.spec — Beweis für PERM-03 (D-15/D-16), die
|
|
* Entweder-oder-Regel (D-04) und die Mandanten-Gegenprüfung vor jedem
|
|
* Grant-Insert (T-15-01). Hand-rolled In-Memory-Prisma-Fake im Stil von
|
|
* groups.service.spec.ts / module-access.service.spec.ts — keine Live-DB,
|
|
* P2002 wird exakt wie ein echter Postgres-Client über den Fehlercode
|
|
* simuliert.
|
|
*
|
|
* Bindung an forTenant() (260909-jts, Aufgabe 3, Befund C uebertragen von
|
|
* groups.service.spec.ts): derselbe Mock wie dort — der gebundene Client
|
|
* ist ein ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN
|
|
* Speicher, das protokolliert, welche Aufrufe ueber ihn liefen. Ein reiner
|
|
* Identitaets-Mock (`forTenant: vi.fn((p) => p)`) koennte einen
|
|
* vergessenen Bindungsaufruf nicht von einem ungebundenen Aufruf
|
|
* unterscheiden.
|
|
*/
|
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
|
}));
|
|
|
|
function makeFakePrisma() {
|
|
const groups = new Map<string, any>();
|
|
const users = new Map<string, any>();
|
|
const memberships = new Map<string, Set<string>>(); // groupId -> Set<userId>
|
|
const membershipSources = new Map<string, string>(); // `${groupId}::${userId}` -> source
|
|
const activations = new Map<string, any>(); // key: tenantId::moduleId
|
|
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
|
const grants = new Map<string, any>();
|
|
let grantCounter = 0;
|
|
|
|
function throwUnique(): never {
|
|
const err: any = new Error('Unique constraint failed');
|
|
err.code = 'P2002';
|
|
throw err;
|
|
}
|
|
|
|
function findGrant(
|
|
tenantId: string,
|
|
moduleId: string,
|
|
groupId?: string | null,
|
|
userId?: string | null,
|
|
) {
|
|
return Array.from(grants.values()).find(
|
|
(g) =>
|
|
g.tenantId === tenantId &&
|
|
g.moduleId === moduleId &&
|
|
(g.groupId ?? null) === (groupId ?? null) &&
|
|
(g.userId ?? null) === (userId ?? null),
|
|
);
|
|
}
|
|
|
|
const fake: any = {
|
|
__seedGroup(group: { id: string; tenantId: string; name: string; internalName?: string | null }) {
|
|
groups.set(group.id, { internalName: null, ...group });
|
|
},
|
|
__seedUser(user: { id: string; tenantId: string }) {
|
|
users.set(user.id, user);
|
|
},
|
|
__seedMembership(groupId: string, userId: string, source: string = 'MANUAL') {
|
|
const set = memberships.get(groupId) ?? new Set<string>();
|
|
set.add(userId);
|
|
memberships.set(groupId, set);
|
|
membershipSources.set(`${groupId}::${userId}`, source);
|
|
},
|
|
__seedActivation(a: {
|
|
tenantId: string;
|
|
moduleId: string;
|
|
isActive: boolean;
|
|
module: { id: string; category: string; name: string };
|
|
}) {
|
|
activations.set(`${a.tenantId}::${a.moduleId}`, a);
|
|
},
|
|
__grantCount() {
|
|
return grants.size;
|
|
},
|
|
group: {
|
|
findFirst: async ({ where }: any) => {
|
|
return (
|
|
Array.from(groups.values()).find(
|
|
(g) => g.id === where.id && g.tenantId === where.tenantId,
|
|
) ?? null
|
|
);
|
|
},
|
|
findMany: async ({ where }: any) => {
|
|
return Array.from(groups.values())
|
|
.filter((g) => g.tenantId === where.tenantId)
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
},
|
|
},
|
|
user: {
|
|
findFirst: async ({ where }: any) => {
|
|
return (
|
|
Array.from(users.values()).find(
|
|
(u) => u.id === where.id && u.tenantId === where.tenantId,
|
|
) ?? null
|
|
);
|
|
},
|
|
},
|
|
tenantModuleActivation: {
|
|
findUnique: async ({ where }: any) => {
|
|
const { tenantId, moduleId } = where.tenantId_moduleId;
|
|
return activations.get(`${tenantId}::${moduleId}`) ?? null;
|
|
},
|
|
findMany: async ({ where }: any) => {
|
|
return Array.from(activations.values()).filter(
|
|
(a) => a.tenantId === where.tenantId && a.isActive === where.isActive,
|
|
);
|
|
},
|
|
},
|
|
moduleGrant: {
|
|
create: async ({ data }: any) => {
|
|
if (findGrant(data.tenantId, data.moduleId, data.groupId, data.userId)) {
|
|
throwUnique();
|
|
}
|
|
grantCounter += 1;
|
|
const record = { id: `grant-${grantCounter}`, createdAt: new Date(), ...data };
|
|
grants.set(record.id, record);
|
|
return record;
|
|
},
|
|
findFirst: async ({ where }: any) => {
|
|
return findGrant(where.tenantId, where.moduleId, where.groupId, where.userId) ?? null;
|
|
},
|
|
findMany: async ({ where }: any) => {
|
|
let rows = Array.from(grants.values()).filter((g) => g.tenantId === where.tenantId);
|
|
|
|
if (where.moduleId !== undefined) {
|
|
rows = rows.filter((g) => g.moduleId === where.moduleId);
|
|
}
|
|
if (where.groupId?.not === null) {
|
|
rows = rows.filter((g) => g.groupId !== null && g.groupId !== undefined);
|
|
}
|
|
if (where.group) {
|
|
const userId = where.group.memberships.some.userId;
|
|
rows = rows
|
|
.filter((g) => g.groupId && memberships.get(g.groupId)?.has(userId))
|
|
.map((g) => ({ ...g, group: groups.get(g.groupId) }));
|
|
} else if (where.userId !== undefined) {
|
|
rows = rows.filter((g) => g.userId === where.userId);
|
|
}
|
|
return rows;
|
|
},
|
|
deleteMany: async ({ where }: any) => {
|
|
let count = 0;
|
|
for (const [id, g] of grants.entries()) {
|
|
if (
|
|
g.tenantId === where.tenantId &&
|
|
g.moduleId === where.moduleId &&
|
|
(where.groupId === undefined || g.groupId === where.groupId) &&
|
|
(where.userId === undefined || g.userId === where.userId)
|
|
) {
|
|
grants.delete(id);
|
|
count += 1;
|
|
}
|
|
}
|
|
return { count };
|
|
},
|
|
},
|
|
groupMembership: {
|
|
findMany: async ({ where }: any) => {
|
|
const userId = where.userId;
|
|
const tenantId = where.group.tenantId;
|
|
const rows: any[] = [];
|
|
for (const [groupId, memberSet] of memberships.entries()) {
|
|
if (!memberSet.has(userId)) continue;
|
|
const group = groups.get(groupId);
|
|
if (!group || group.tenantId !== tenantId) continue;
|
|
rows.push({
|
|
groupId,
|
|
userId,
|
|
source: membershipSources.get(`${groupId}::${userId}`) ?? 'MANUAL',
|
|
group: { id: group.id, name: group.name, internalName: group.internalName ?? null },
|
|
});
|
|
}
|
|
return rows;
|
|
},
|
|
},
|
|
// --- Bindungsnachweis (260909-jts, 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 fake;
|
|
}
|
|
|
|
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
|
|
* Herkunfts-Tenant protokollierenden Wrapper versieht. */
|
|
const BOUND_MODEL_NAMES = ['group', 'user', 'tenantModuleActivation', 'moduleGrant', 'groupMembership'];
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
function seedBase(prisma: ReturnType<typeof makeFakePrisma>) {
|
|
prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Gruppe A' });
|
|
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
|
prisma.__seedActivation({
|
|
tenantId: 't1',
|
|
moduleId: 'mod-1',
|
|
isActive: true,
|
|
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
|
|
});
|
|
}
|
|
|
|
describe('ModuleGrantsService.grant', () => {
|
|
it('legt einen Gruppen-Grant an und gibt ihn zurück', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
const result = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
expect(result.moduleId).toBe('mod-1');
|
|
expect(result.groupId).toBe('g1');
|
|
expect(result.userId ?? null).toBeNull();
|
|
});
|
|
|
|
it('legt einen Direkt-Grant an und gibt ihn zurück', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
const result = await service.grant('t1', { moduleId: 'mod-1', userId: 'u1' });
|
|
|
|
expect(result.moduleId).toBe('mod-1');
|
|
expect(result.userId).toBe('u1');
|
|
expect(result.groupId ?? null).toBeNull();
|
|
});
|
|
|
|
it('wirft BadRequestException, wenn groupId UND userId gesetzt sind', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
await expect(
|
|
service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', userId: 'u1' }),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(prisma.__grantCount()).toBe(0);
|
|
});
|
|
|
|
it('wirft BadRequestException, wenn weder groupId noch userId gesetzt sind', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
await expect(service.grant('t1', { moduleId: 'mod-1' })).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(prisma.__grantCount()).toBe(0);
|
|
});
|
|
|
|
// Diese beiden Faelle (T-JTS-03, 260910-jab, Aufgabe 2) beweisen, dass
|
|
// assertTargetBelongsToTenant() weiterhin im Anwendungscode scheitert —
|
|
// nicht erst in der Datenbank. Seit
|
|
// 20260910120000_rls_widen_membership_grant_and_platform_read zieht auch
|
|
// die Datenbankregel dieselbe Grenze, aber erst NACH dem Scharfschalten
|
|
// (#18 ist weiterhin aus). Wuerde assertTargetBelongsToTenant() im
|
|
// Vertrauen auf "das macht jetzt die Datenbank" entfernt, werden GENAU
|
|
// diese beiden Faelle rot: der Fake hier hat keine RLS-Policy, nur das
|
|
// reale ModuleGrant/Group/User-Schema tut das.
|
|
it('wirft NotFoundException für eine groupId aus einem anderen Mandanten und legt nichts an', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedGroup({ id: 'g-foreign', tenantId: 't2', name: 'Fremde Gruppe' });
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
await expect(
|
|
service.grant('t1', { moduleId: 'mod-1', groupId: 'g-foreign' }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
expect(prisma.__grantCount()).toBe(0);
|
|
});
|
|
|
|
it('wirft NotFoundException für eine userId aus einem anderen Mandanten und legt nichts an', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' });
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
await expect(
|
|
service.grant('t1', { moduleId: 'mod-1', userId: 'u-foreign' }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
expect(prisma.__grantCount()).toBe(0);
|
|
});
|
|
|
|
it('wirft BadRequestException, wenn keine aktive TenantModuleActivation für das Modul existiert', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Gruppe A' });
|
|
// keine Activation geseedet
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
await expect(
|
|
service.grant('t1', { moduleId: 'mod-unaktiviert', groupId: 'g1' }),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(prisma.__grantCount()).toBe(0);
|
|
});
|
|
|
|
it('idempotency: ein zweiter Grant auf dieselbe Kombination legt keinen zweiten Datensatz an und wirft nicht', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
const first = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
const second = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
expect(second.id).toBe(first.id);
|
|
expect(prisma.__grantCount()).toBe(1);
|
|
});
|
|
|
|
it('concurrency: zwei parallele Grant-Erstellungen für dieselbe Kombination führen zu genau einer Zeile, keine der beiden wirft', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
const [first, second] = await Promise.all([
|
|
service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }),
|
|
service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }),
|
|
]);
|
|
|
|
expect(first.groupId).toBe('g1');
|
|
expect(second.groupId).toBe('g1');
|
|
expect(prisma.__grantCount()).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('ModuleGrantsService.revoke', () => {
|
|
it('entfernt einen bestehenden Grant', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
expect(prisma.__grantCount()).toBe(0);
|
|
});
|
|
|
|
it('idempotency: ein zweites Entziehen eines bereits entzogenen Grants ist folgenlos und wirft nicht', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
await expect(
|
|
service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' }),
|
|
).resolves.not.toThrow();
|
|
expect(prisma.__grantCount()).toBe(0);
|
|
});
|
|
|
|
it('entfernt nichts, wenn die groupId aus einem anderen Mandanten stammt', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
await service.revoke('t2', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
expect(prisma.__grantCount()).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('ModuleGrantsService.getMatrix', () => {
|
|
it('liefert modules, groups und grants; Module nach category+name, Gruppen nach name sortiert', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.__seedActivation({
|
|
tenantId: 't1',
|
|
moduleId: 'mod-b',
|
|
isActive: true,
|
|
module: { id: 'mod-b', category: 'zzz', name: 'B-Modul' },
|
|
});
|
|
prisma.__seedActivation({
|
|
tenantId: 't1',
|
|
moduleId: 'mod-a',
|
|
isActive: true,
|
|
module: { id: 'mod-a', category: 'aaa', name: 'A-Modul' },
|
|
});
|
|
prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Zeta' });
|
|
prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Alpha' });
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
await service.grant('t1', { moduleId: 'mod-a', groupId: 'g1' });
|
|
|
|
const matrix = await service.getMatrix('t1');
|
|
|
|
expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-a', 'mod-b']);
|
|
expect(matrix.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Zeta']);
|
|
expect(matrix.grants).toEqual([{ moduleId: 'mod-a', groupId: 'g1' }]);
|
|
});
|
|
|
|
it('empty: ein Mandant ohne Gruppen liefert eine leere Gruppenliste und wirft nicht', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.__seedActivation({
|
|
tenantId: 't1',
|
|
moduleId: 'mod-1',
|
|
isActive: true,
|
|
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
|
|
});
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
const matrix = await service.getMatrix('t1');
|
|
|
|
expect(matrix.groups).toEqual([]);
|
|
expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-1']);
|
|
});
|
|
|
|
it('ordering: die Matrix-Antwort liefert dieselbe Reihenfolge über wiederholte Aufrufe', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.__seedActivation({
|
|
tenantId: 't1',
|
|
moduleId: 'mod-b',
|
|
isActive: true,
|
|
module: { id: 'mod-b', category: 'zzz', name: 'B-Modul' },
|
|
});
|
|
prisma.__seedActivation({
|
|
tenantId: 't1',
|
|
moduleId: 'mod-a',
|
|
isActive: true,
|
|
module: { id: 'mod-a', category: 'aaa', name: 'A-Modul' },
|
|
});
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
const first = await service.getMatrix('t1');
|
|
const second = await service.getMatrix('t1');
|
|
|
|
expect(first.modules.map((m: any) => m.id)).toEqual(second.modules.map((m: any) => m.id));
|
|
});
|
|
});
|
|
|
|
describe('ModuleGrantsService.getUserAccess', () => {
|
|
it('liefert je aktivem Modul die geerbten Gruppen und den Direkt-Grant-Status', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedMembership('g1', 'u1');
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
const result = await service.getUserAccess('t1', 'u1');
|
|
|
|
expect(result.modules).toEqual([
|
|
{
|
|
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
|
|
viaGroups: ['Gruppe A'],
|
|
direct: false,
|
|
},
|
|
]);
|
|
expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]);
|
|
});
|
|
|
|
it('adjacency: ein Direkt-Grant UND ein Gruppen-Grant auf dasselbe Modul erscheinen gleichzeitig, keiner verdrängt den anderen', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedMembership('g1', 'u1');
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
await service.grant('t1', { moduleId: 'mod-1', userId: 'u1' });
|
|
|
|
const result = await service.getUserAccess('t1', 'u1');
|
|
|
|
expect(result.modules[0].viaGroups).toEqual(['Gruppe A']);
|
|
expect(result.modules[0].direct).toBe(true);
|
|
});
|
|
|
|
it('wirft NotFoundException für eine userId aus einem anderen Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' });
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
await expect(service.getUserAccess('t1', 'u-foreign')).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
});
|
|
|
|
it('REGRESSION: Mitglied einer Gruppe ohne Modul-Freigabe bleibt sichtbar', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedMembership('g1', 'u1');
|
|
// Bewusst KEIN Grant für g1 auf mod-1.
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
const result = await service.getUserAccess('t1', 'u1');
|
|
|
|
expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]);
|
|
expect(result.modules.every((m: any) => m.viaGroups.length === 0)).toBe(true);
|
|
});
|
|
|
|
it('Cross-Tenant: eine Mitgliedschaft in einer Gruppe eines fremden Mandanten erscheint nicht in groups', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedGroup({ id: 'g-foreign', tenantId: 't2', name: 'Fremde Gruppe' });
|
|
prisma.__seedMembership('g-foreign', 'u1');
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
const result = await service.getUserAccess('t1', 'u1');
|
|
|
|
expect(result.groups).toEqual([]);
|
|
});
|
|
|
|
it('Herkunft: eine mit source LDAP geseedete Mitgliedschaft kommt mit source LDAP zurück', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedMembership('g1', 'u1', 'LDAP');
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
const result = await service.getUserAccess('t1', 'u1');
|
|
|
|
expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'LDAP' }]);
|
|
});
|
|
|
|
it('ohne aktives Modul im Mandanten: modules ist leer, groups trotzdem befüllt', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Gruppe A' });
|
|
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
|
// keine Activation geseedet
|
|
prisma.__seedMembership('g1', 'u1');
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
const result = await service.getUserAccess('t1', 'u1');
|
|
|
|
expect(result.modules).toEqual([]);
|
|
expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]);
|
|
});
|
|
|
|
it('Sortierung: groups ist alphabetisch nach name stabil über wiederholte Aufrufe', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Alpha' });
|
|
prisma.__seedMembership('g1', 'u1');
|
|
prisma.__seedMembership('g2', 'u1');
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
const first = await service.getUserAccess('t1', 'u1');
|
|
const second = await service.getUserAccess('t1', 'u1');
|
|
|
|
expect(first.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Gruppe A']);
|
|
expect(second.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Gruppe A']);
|
|
});
|
|
});
|
|
|
|
describe('ModuleGrantsService.getUserAccess — Anzeigename mit Fallback (D-04)', () => {
|
|
it('gesetzter internalName: beide Projektionen (groups[].name, modules[].viaGroups) liefern den internen Namen', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'AD-Rohname', internalName: 'Vertrieb' });
|
|
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
|
prisma.__seedActivation({
|
|
tenantId: 't1',
|
|
moduleId: 'mod-1',
|
|
isActive: true,
|
|
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
|
|
});
|
|
prisma.__seedMembership('g1', 'u1');
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
const result = await service.getUserAccess('t1', 'u1');
|
|
|
|
expect(result.groups).toEqual([{ id: 'g1', name: 'Vertrieb', source: 'MANUAL' }]);
|
|
expect(result.modules[0].viaGroups).toEqual(['Vertrieb']);
|
|
});
|
|
|
|
it('kein internalName (null): beide Projektionen fallen auf name zurueck', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedMembership('g1', 'u1');
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
const result = await service.getUserAccess('t1', 'u1');
|
|
|
|
expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]);
|
|
expect(result.modules[0].viaGroups).toEqual(['Gruppe A']);
|
|
});
|
|
|
|
it('Sortierung laeuft ueber den angezeigten Namen, nicht ueber die Datenbankspalte name', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
|
// g1 traegt in der DB den Namen "Zebra-AD", zeigt aber "Alpha-Anzeige" an;
|
|
// g2 traegt "Beta" ohne internalName. Alphabetisch nach ANGEZEIGTEM Namen
|
|
// muesste g1 (Alpha-Anzeige) vor g2 (Beta) stehen, waehrend eine Sortierung
|
|
// ueber die rohe name-Spalte g2 (Beta) vor g1 (Zebra-AD) haette gestellt.
|
|
prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Zebra-AD', internalName: 'Alpha-Anzeige' });
|
|
prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Beta' });
|
|
prisma.__seedMembership('g1', 'u1');
|
|
prisma.__seedMembership('g2', 'u1');
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
const result = await service.getUserAccess('t1', 'u1');
|
|
|
|
expect(result.groups.map((g: any) => g.name)).toEqual(['Alpha-Anzeige', 'Beta']);
|
|
});
|
|
});
|
|
|
|
describe('ModuleGrantsService — Logging (D-23)', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('grant schreibt eine Logzeile mit Mandant, Modul, Ziel und Aktion', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
expect(logSpy).toHaveBeenCalled();
|
|
const message = logSpy.mock.calls[0][0] as string;
|
|
expect(message).toContain('t1');
|
|
expect(message).toContain('mod-1');
|
|
expect(message).toContain('g1');
|
|
});
|
|
|
|
it('revoke schreibt eine Logzeile mit Mandant, Modul, Ziel und Aktion', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
logSpy.mockClear();
|
|
|
|
await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
expect(logSpy).toHaveBeenCalled();
|
|
const message = logSpy.mock.calls[0][0] as string;
|
|
expect(message).toContain('t1');
|
|
expect(message).toContain('mod-1');
|
|
expect(message).toContain('g1');
|
|
});
|
|
});
|
|
|
|
// --- Bindung an forTenant() (260909-jts, Aufgabe 3) -------------------------
|
|
|
|
describe('ModuleGrantsService — Bindung an forTenant() (260909-jts)', () => {
|
|
it('grant() bindet die Mandanten-Gegenpruefung, die Aktivierungspruefung und moduleGrant.create an den uebergebenen Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
|
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findUnique');
|
|
expectBoundCall(prisma, 't1', 'moduleGrant', 'create');
|
|
});
|
|
|
|
it('grant() bindet auch die Mandanten-Gegenpruefung fuer eine userId und bleibt wirksam gegen einen fremden Benutzer (T-15-01)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' });
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
await expect(
|
|
service.grant('t1', { moduleId: 'mod-1', userId: 'u-foreign' }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
|
|
expectBoundCall(prisma, 't1', 'user', 'findFirst');
|
|
});
|
|
|
|
it('revoke() bindet moduleGrant.deleteMany an den uebergebenen Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
expectBoundCall(prisma, 't1', 'moduleGrant', 'deleteMany');
|
|
});
|
|
|
|
it('getMatrix() bindet alle drei parallelen Teilabfragen (tenantModuleActivation, group, moduleGrant) an DENSELBEN gebundenen Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
await service.getMatrix('t1');
|
|
|
|
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
|
|
expectBoundCall(prisma, 't1', 'group', 'findMany');
|
|
expectBoundCall(prisma, 't1', 'moduleGrant', 'findMany');
|
|
});
|
|
|
|
it('getUserAccess() bindet alle vier parallelen Teilabfragen (tenantModuleActivation, moduleGrant x2, groupMembership) an DENSELBEN gebundenen Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedMembership('g1', 'u1');
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
|
|
|
await service.getUserAccess('t1', 'u1');
|
|
|
|
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
|
|
expectBoundCall(prisma, 't1', 'moduleGrant', 'findMany');
|
|
expectBoundCall(prisma, 't1', 'groupMembership', 'findMany');
|
|
});
|
|
|
|
it('getUserAccess() bindet weiterhin die Mandanten-Gegenpruefung — sie wird durch die Bindung NICHT ersetzt', async () => {
|
|
const prisma = makeFakePrisma();
|
|
seedBase(prisma);
|
|
prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' });
|
|
const service = new ModuleGrantsService(prisma as any);
|
|
|
|
await expect(service.getUserAccess('t1', 'u-foreign')).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
|
|
expectBoundCall(prisma, 't1', 'user', 'findFirst');
|
|
});
|
|
});
|