25c8db746a
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
420 lines
16 KiB
TypeScript
420 lines
16 KiB
TypeScript
import { ConflictException } from '@nestjs/common';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { UserService } from './user.service';
|
|
|
|
/**
|
|
* UserService — Zwei-Klienten-Nachweis und Linie je Methode (260910-das,
|
|
* Aufgabe 2, Befund C).
|
|
*
|
|
* Diese Datei hatte bisher KEINE Attrappe fuer das Bindungshilfsmittel: der
|
|
* Prisma-Ersatz war ein nacktes Objekt mit genau einem Eintrag
|
|
* (`user.create`), es gab keine `forTenant`-Attrappe. Nach der Umstellung
|
|
* waeren die vier bestehenden Testfaelle rot geworden, aber aus dem
|
|
* FALSCHEN Grund (das Hilfsmittel bekaeme einen unbrauchbaren Klienten),
|
|
* nicht wegen einer vergessenen Bindung. Muster nach
|
|
* `groups.service.spec.ts`: `forTenant(prisma, tenantId)` delegiert an
|
|
* `prisma.__makeBoundClient(tenantId)`, ein protokollierender Wrapper um
|
|
* DIESELBEN Maps wie der ungebundene Zugriff — der ungebundene Ersatz
|
|
* protokolliert nicht, der gebundene schon.
|
|
*
|
|
* Der Speicher bildet zusaetzlich die plattformweite Eindeutigkeit von
|
|
* `username` nach: ein Einfuegen mit einem bereits vergebenen Benutzernamen
|
|
* wirft einen Fehler mit dem Prisma-Fehlercode fuer Eindeutigkeitsverletzungen
|
|
* (P2002), UNABHAENGIG davon, welchem Mandanten der bestehende Halter
|
|
* gehoert — ohne diese Nachbildung ist die zentrale Aussage dieses Bereichs
|
|
* nicht pruefbar.
|
|
*/
|
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
|
}));
|
|
|
|
function makeFakePrisma() {
|
|
const users = new Map<string, any>();
|
|
const tenants = new Map<string, any>();
|
|
let userCounter = 0;
|
|
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
|
|
|
function throwUnique(): never {
|
|
const err: any = new Error('Unique constraint failed on the fields: (`username`)');
|
|
err.code = 'P2002';
|
|
throw err;
|
|
}
|
|
|
|
function throwNotFound(): never {
|
|
const err: any = new Error('Record to update/delete not found');
|
|
err.code = 'P2025';
|
|
throw err;
|
|
}
|
|
|
|
function findByUsernameGlobal(username: string, excludeId?: string) {
|
|
return Array.from(users.values()).find(
|
|
(u) => u.username === username && u.id !== excludeId,
|
|
);
|
|
}
|
|
|
|
// Ungebundene (RLS-freie) Grundoperationen — fuer findByUsername() und
|
|
// jede andere direkte this.prisma.user.*-Nutzung.
|
|
const rawUser = {
|
|
findUnique: async ({ where }: any) => {
|
|
if (where.username !== undefined) {
|
|
return findByUsernameGlobal(where.username) ?? null;
|
|
}
|
|
if (where.id !== undefined) {
|
|
return users.get(where.id) ?? null;
|
|
}
|
|
return null;
|
|
},
|
|
findMany: async ({ where }: any = {}) => {
|
|
let rows = Array.from(users.values());
|
|
if (where?.tenantId !== undefined) {
|
|
rows = rows.filter((u) => u.tenantId === where.tenantId);
|
|
}
|
|
return [...rows].sort((a, b) => a.username.localeCompare(b.username));
|
|
},
|
|
create: async ({ data }: any) => {
|
|
if (findByUsernameGlobal(data.username)) throwUnique();
|
|
userCounter += 1;
|
|
const record = { id: `u-${userCounter}`, createdAt: new Date(), ...data };
|
|
users.set(record.id, record);
|
|
return record;
|
|
},
|
|
update: async ({ where, data }: any) => {
|
|
const existing = users.get(where.id);
|
|
if (!existing) throwNotFound();
|
|
if (data.username && findByUsernameGlobal(data.username, existing.id)) throwUnique();
|
|
const record = { ...existing, ...data };
|
|
users.set(where.id, record);
|
|
return record;
|
|
},
|
|
delete: async ({ where }: any) => {
|
|
const existing = users.get(where.id);
|
|
if (!existing) throwNotFound();
|
|
users.delete(where.id);
|
|
return existing;
|
|
},
|
|
};
|
|
|
|
// Gebundene (RLS-simulierende) Fassung: jede Methode filtert zusaetzlich
|
|
// auf den Mandantenkontext, GENAU wie die ausgelieferte Policy es tut —
|
|
// unabhaengig davon, ob der Aufrufer selbst ein explizites tenantId ins
|
|
// where schreibt (UserService tut das bewusst NICHT fuer findById/
|
|
// update/deactivate/delete, siehe Befund G).
|
|
function makeScopedUser(tenantId: string) {
|
|
return {
|
|
findUnique: async (args: any) => {
|
|
const row = await rawUser.findUnique(args);
|
|
return row && row.tenantId === tenantId ? row : null;
|
|
},
|
|
findMany: async (args: any) => {
|
|
const rows = await rawUser.findMany(args);
|
|
return rows.filter((r: any) => r.tenantId === tenantId);
|
|
},
|
|
create: async (args: any) => {
|
|
if (args.data.tenantId !== tenantId) {
|
|
const err: any = new Error(
|
|
'new row violates row-level security policy for table "User"',
|
|
);
|
|
err.code = '42501';
|
|
throw err;
|
|
}
|
|
return rawUser.create(args);
|
|
},
|
|
update: async (args: any) => {
|
|
const existing = users.get(args.where.id);
|
|
if (!existing || existing.tenantId !== tenantId) throwNotFound();
|
|
return rawUser.update(args);
|
|
},
|
|
delete: async (args: any) => {
|
|
const existing = users.get(args.where.id);
|
|
if (!existing || existing.tenantId !== tenantId) throwNotFound();
|
|
return rawUser.delete(args);
|
|
},
|
|
};
|
|
}
|
|
|
|
const fake: any = {
|
|
__seedUser(user: any) {
|
|
users.set(user.id, user);
|
|
},
|
|
__seedTenant(tenant: any) {
|
|
tenants.set(tenant.id, tenant);
|
|
},
|
|
user: rawUser,
|
|
tenant: {
|
|
findMany: async () => Array.from(tenants.values()),
|
|
},
|
|
__boundCallLog: boundCallLog,
|
|
__makeBoundClient(tenantId: string) {
|
|
const scoped = makeScopedUser(tenantId);
|
|
const wrap = (method: string, fn: (args: any) => any) => {
|
|
return async (args: any) => {
|
|
boundCallLog.push({ tenantId, model: 'user', method });
|
|
return fn(args);
|
|
};
|
|
};
|
|
return {
|
|
__isBoundClient: true,
|
|
__tenantId: tenantId,
|
|
user: {
|
|
findUnique: wrap('findUnique', scoped.findUnique),
|
|
findMany: wrap('findMany', scoped.findMany),
|
|
create: wrap('create', scoped.create),
|
|
update: wrap('update', scoped.update),
|
|
delete: wrap('delete', scoped.delete),
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
return fake;
|
|
}
|
|
|
|
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 expectNotBoundCall(prisma: any, model: string, method: string) {
|
|
const found = prisma.__boundCallLog.some((c: any) => c.model === model && c.method === method);
|
|
expect(
|
|
found,
|
|
`unerwarteter gebundener Aufruf ${model}.${method} im Protokoll — diese Methode soll UNGEBUNDEN bleiben: ${JSON.stringify(prisma.__boundCallLog)}`,
|
|
).toBe(false);
|
|
}
|
|
|
|
describe('UserService', () => {
|
|
describe('create — Standardgruppen-Mitgliedschaft (D-11/D-12) und Bindung', () => {
|
|
let prisma: any;
|
|
let groupsService: any;
|
|
let service: UserService;
|
|
|
|
const baseData = { username: 'Alice', email: 'alice@example.com', tenantId: 't1' };
|
|
|
|
beforeEach(() => {
|
|
prisma = makeFakePrisma();
|
|
groupsService = { addUserToDefaultGroup: vi.fn().mockResolvedValue(undefined) };
|
|
service = new UserService(prisma, groupsService);
|
|
});
|
|
|
|
it('Test 1: steht mit der uebergebenen Mandantenkennung im Bindungsprotokoll, und ruft nach der Anlage genau einmal addUserToDefaultGroup mit derselben tenantId und der frisch erzeugten userId auf', async () => {
|
|
const result = await service.create(baseData);
|
|
|
|
expectBoundCall(prisma, 't1', 'user', 'create');
|
|
expect(result.tenantId).toBe('t1');
|
|
expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1);
|
|
expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledWith('t1', result.id);
|
|
});
|
|
|
|
it('legt den Benutzer bei einem Mandanten ohne markierte Standardgruppe trotzdem an — addUserToDefaultGroup bleibt folgenlos, die Anlage schlägt nicht fehl', async () => {
|
|
const result = await service.create(baseData);
|
|
|
|
expect(result.username).toBe('alice');
|
|
expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('gibt den Benutzer trotz werfendem addUserToDefaultGroup zurück — der Fehler wird protokolliert, nicht propagiert', async () => {
|
|
groupsService.addUserToDefaultGroup.mockRejectedValue(new Error('boom'));
|
|
|
|
await expect(service.create(baseData)).resolves.toHaveProperty('username', 'alice');
|
|
});
|
|
|
|
it('gibt weiterhin den erzeugten Benutzerdatensatz zurück; die Signatur bleibt unverändert', async () => {
|
|
const result = await service.create(baseData);
|
|
|
|
expect(result).toHaveProperty('id');
|
|
expect(result).toMatchObject({
|
|
username: 'alice',
|
|
email: 'alice@example.com',
|
|
tenantId: 't1',
|
|
});
|
|
});
|
|
|
|
it('Test 2: wirft bei einem Benutzernamen, den ein Benutzer eines ANDEREN Mandanten bereits hält, eine verständliche deutsche Konfliktmeldung statt eines durchgereichten Datenbankfehlers — und nennt weder den Halter noch dessen Mandanten', async () => {
|
|
prisma.__seedUser({ id: 'existing', username: 'bob', tenantId: 't2' });
|
|
|
|
let caught: any;
|
|
try {
|
|
await service.create({ username: 'Bob', email: 'bob2@example.com', tenantId: 't1' });
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(ConflictException);
|
|
expect(caught.message).not.toContain('t2');
|
|
expect(caught.message).not.toContain('existing');
|
|
expect(caught.message.toLowerCase()).toMatch(/benutzername|adresse/);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* quick-260925-bow (D-04): neue Benutzer bekommen die laufende freigegebene
|
|
* Version eingetragen, damit sie kein "Was ist neu"-Fenster mit Altlasten
|
|
* sehen. Der Wert ist eine Eigenschaft des Servers (APP_VERSION), nicht des
|
|
* Aufrufers — die Signatur von create() bekommt kein neues Feld.
|
|
*/
|
|
describe('create — lastSeenReleaseVersion (quick-260925-bow)', () => {
|
|
let prisma: any;
|
|
let service: UserService;
|
|
|
|
beforeEach(() => {
|
|
prisma = makeFakePrisma();
|
|
service = new UserService(prisma, { addUserToDefaultGroup: vi.fn().mockResolvedValue(undefined) } as any);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('traegt bei freigegebener laufender Version X.Y.Z ein (Describe-Stand gekuerzt)', async () => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3-3-gabc1234');
|
|
const result = await service.create({ username: 'neu', email: 'neu@example.com', tenantId: 't1' });
|
|
expect(result.lastSeenReleaseVersion).toBe('10.2.3');
|
|
expectBoundCall(prisma, 't1', 'user', 'create');
|
|
});
|
|
|
|
it('traegt auf dev-Staenden null ein', async () => {
|
|
vi.stubEnv('APP_VERSION', 'dev');
|
|
const result = await service.create({ username: 'neu2', email: 'neu2@example.com', tenantId: 't1' });
|
|
expect(result.lastSeenReleaseVersion).toBeNull();
|
|
});
|
|
|
|
it('traegt ohne APP_VERSION null ein', async () => {
|
|
vi.stubEnv('APP_VERSION', undefined);
|
|
const result = await service.create({ username: 'neu3', email: 'neu3@example.com', tenantId: 't1' });
|
|
expect(result.lastSeenReleaseVersion).toBeNull();
|
|
});
|
|
|
|
it('ein von aussen untergeschobenes Feld setzt den Wert nicht (kein Parameter der Signatur)', async () => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
|
const result = await service.create({
|
|
username: 'neu4',
|
|
email: 'neu4@example.com',
|
|
tenantId: 't1',
|
|
lastSeenReleaseVersion: '99.0.0',
|
|
} as unknown as Parameters<UserService['create']>[0]);
|
|
expect(result.lastSeenReleaseVersion).toBe('10.2.3');
|
|
});
|
|
});
|
|
|
|
describe('findById', () => {
|
|
let prisma: any;
|
|
let service: UserService;
|
|
|
|
beforeEach(() => {
|
|
prisma = makeFakePrisma();
|
|
prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' });
|
|
prisma.__seedUser({ id: 'u-b', username: 'bob', tenantId: 't2' });
|
|
service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any);
|
|
});
|
|
|
|
it('Test 4: steht gebunden im Protokoll und liefert einen Benutzer eines anderen Mandanten NICHT', async () => {
|
|
const own = await service.findById('t1', 'u-a');
|
|
expect(own).toMatchObject({ id: 'u-a' });
|
|
expectBoundCall(prisma, 't1', 'user', 'findUnique');
|
|
|
|
const foreign = await service.findById('t1', 'u-b');
|
|
expect(foreign).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
let prisma: any;
|
|
let service: UserService;
|
|
|
|
beforeEach(() => {
|
|
prisma = makeFakePrisma();
|
|
prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' });
|
|
prisma.__seedUser({ id: 'u-b', username: 'bob', tenantId: 't2' });
|
|
service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any);
|
|
});
|
|
|
|
it('Test 3: steht gebunden im Protokoll, und ein Namenswechsel auf einen fremd gehaltenen Benutzernamen wirft dieselbe Art von Konfliktmeldung', async () => {
|
|
await expect(service.update('t1', 'u-a', { username: 'Bob' })).rejects.toBeInstanceOf(
|
|
ConflictException,
|
|
);
|
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
|
});
|
|
});
|
|
|
|
describe('deactivate/delete', () => {
|
|
let prisma: any;
|
|
let service: UserService;
|
|
|
|
beforeEach(() => {
|
|
prisma = makeFakePrisma();
|
|
prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' });
|
|
service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any);
|
|
});
|
|
|
|
it('Test 5: deactivate steht gebunden im Protokoll', async () => {
|
|
await service.deactivate('t1', 'u-a');
|
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
|
});
|
|
|
|
it('Test 5: delete steht gebunden im Protokoll', async () => {
|
|
await service.delete('t1', 'u-a');
|
|
expectBoundCall(prisma, 't1', 'user', 'delete');
|
|
});
|
|
});
|
|
|
|
describe('Plattform-Administratorsicht (Befund F)', () => {
|
|
let prisma: any;
|
|
let service: UserService;
|
|
|
|
beforeEach(() => {
|
|
prisma = makeFakePrisma();
|
|
prisma.__seedTenant({ id: 't1' });
|
|
prisma.__seedTenant({ id: 't2' });
|
|
prisma.__seedUser({ id: 'u-carol', username: 'carol', tenantId: 't1' });
|
|
prisma.__seedUser({ id: 'u-alice', username: 'alice', tenantId: 't1' });
|
|
prisma.__seedUser({ id: 'u-bob', username: 'bob', tenantId: 't2' });
|
|
service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any);
|
|
});
|
|
|
|
it('Test 6: liest die Mandanten UNGEBUNDEN und danach je Mandant GEBUNDEN — im Protokoll steht je Mandant genau ein Eintrag, das Ergebnis enthält die Benutzer beider Mandanten in der bisherigen Sortierung nach Benutzername', async () => {
|
|
const result = await service.findAllForPlatformAdmin();
|
|
|
|
expect(result.map((u: any) => u.username)).toEqual(['alice', 'bob', 'carol']);
|
|
expectBoundCall(prisma, 't1', 'user', 'findMany');
|
|
expectBoundCall(prisma, 't2', 'user', 'findMany');
|
|
|
|
const entriesFor = (tenantId: string) =>
|
|
prisma.__boundCallLog.filter(
|
|
(c: any) => c.tenantId === tenantId && c.model === 'user' && c.method === 'findMany',
|
|
).length;
|
|
expect(entriesFor('t1')).toBe(1);
|
|
expect(entriesFor('t2')).toBe(1);
|
|
});
|
|
|
|
it('Test 7: findet einen Benutzer eines fremden Mandanten über einen GEBUNDENEN Lesezugriff je Mandant — im Protokoll nachweisbar, nicht nur am Ergebnis', async () => {
|
|
const found = await service.findByIdForPlatformAdmin('u-bob');
|
|
|
|
expect(found).toMatchObject({ id: 'u-bob', tenantId: 't2' });
|
|
expectBoundCall(prisma, 't2', 'user', 'findUnique');
|
|
});
|
|
|
|
it('liefert null, wenn keine Mandant die Kennung besitzt', async () => {
|
|
const found = await service.findByIdForPlatformAdmin('unbekannt');
|
|
expect(found).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('findByUsername (bewusst UNGEBUNDEN)', () => {
|
|
it('Test 8: steht NICHT im Bindungsprotokoll — das Fehlen der Bindung ist hier die bestandene Erwartung, damit niemand sie später als vergessene Bindung "repariert"', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' });
|
|
const service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any);
|
|
|
|
const found = await service.findByUsername('Alice');
|
|
|
|
expect(found).toMatchObject({ id: 'u-a' });
|
|
expectNotBoundCall(prisma, 'user', 'findUnique');
|
|
});
|
|
});
|
|
});
|