76f6d87973
- PATCH me/dashboard-background: Allowlist, UUID-Bildkennung, Mandantenbindung - getMe liefert dashboardBackground normalisiert - Web: Uebernahme der alten localStorage-Wahl, Hook liest aus dem Auth-Store Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
788 lines
26 KiB
TypeScript
788 lines
26 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { Role } from '@prisma/client';
|
|
import { AuthService } from './auth.service';
|
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
|
|
|
/**
|
|
* Aufgabe 2 (260911-fh9): die Identitaets-Attrappe verschwindet. Der
|
|
* Nachbau bekommt zwei UNTERSCHEIDBARE Klienten, in der Form von
|
|
* `user.service.spec.ts`/`tenant.controller.spec.ts`: `forTenant()` wird
|
|
* auf `__makeBoundClient(tenantId)` umgeleitet. Der UNGEBUNDENE Nachbau
|
|
* (der ungebundene Basisclient selbst) hat `$queryRaw` und
|
|
* `__makeBoundClient` — aber KEIN `user`- und KEIN `passwordResetToken`-
|
|
* Modell: ein versehentlich ungebundener Modellzugriff scheitert mit
|
|
* "Cannot read properties of undefined" (die dkv-Form der Falsifizierung).
|
|
* Der GEBUNDENE Klient hat `user`/`passwordResetToken`, aber KEIN
|
|
* `$queryRaw`: eine gebundene Anmeldesuche scheitert ebenso hart.
|
|
*/
|
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
|
|
}));
|
|
|
|
/**
|
|
* Baut eine Tagged-Template-Attrappe fuer prisma.$queryRaw, die die
|
|
* uebergebenen SQL-Textstuecke und interpolierten Werte aufzeichnet und ein
|
|
* konfigurierbares Ergebnis liefert — ohne laufende Datenbank.
|
|
*/
|
|
function fakeQueryRaw(resultsByCall: unknown[][]) {
|
|
let callIndex = 0;
|
|
const calls: { strings: TemplateStringsArray; values: unknown[] }[] = [];
|
|
const fn = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
calls.push({ strings, values });
|
|
const result = resultsByCall[callIndex] ?? [];
|
|
callIndex += 1;
|
|
return Promise.resolve(result);
|
|
});
|
|
return { fn, calls };
|
|
}
|
|
|
|
interface FakeUserRow {
|
|
id: string;
|
|
tenantId: string;
|
|
username: string;
|
|
email?: string | null;
|
|
passwordHash: string | null;
|
|
ldapDn: string | null;
|
|
isActive: boolean;
|
|
role: string;
|
|
displayName: string | null;
|
|
mustChangePassword: boolean;
|
|
avatarPath?: string | null;
|
|
accentColor?: string | null;
|
|
dashboardBackground?: unknown;
|
|
}
|
|
|
|
interface BoundCall {
|
|
tenantId: string;
|
|
model: 'user' | 'passwordResetToken';
|
|
method: string;
|
|
args: any;
|
|
}
|
|
|
|
/**
|
|
* Zwei-Klienten-Nachbau (Muster `user.service.spec.ts`): `users` und
|
|
* `resetTokens` sind das gemeinsame Gedaechtnis, der ungebundene Klient
|
|
* (`$queryRaw`) und der gebundene Klient (`__makeBoundClient`) greifen auf
|
|
* DIESELBEN Karten zu, protokollieren aber unterschiedlich — der
|
|
* ungebundene protokolliert nicht, der gebundene schon.
|
|
*/
|
|
function makeFakePrisma(userRows: FakeUserRow[] = [], queryRawResults: unknown[][] = [[]]) {
|
|
const users = new Map(userRows.map((u) => [u.id, { ...u }]));
|
|
const resetTokens: any[] = [];
|
|
const boundCallLog: BoundCall[] = [];
|
|
const queryRaw = fakeQueryRaw(queryRawResults);
|
|
|
|
function throwNotFound(): never {
|
|
const err: any = new Error('Record to update not found');
|
|
err.code = 'P2025';
|
|
throw err;
|
|
}
|
|
|
|
function makeScopedUser(tenantId: string) {
|
|
return {
|
|
findUnique: async ({ where, select }: any) => {
|
|
boundCallLog.push({ tenantId, model: 'user', method: 'findUnique', args: { where, select } });
|
|
const row = users.get(where.id);
|
|
if (!row || row.tenantId !== tenantId) return null;
|
|
if (!select) return { ...row };
|
|
const picked: any = {};
|
|
for (const key of Object.keys(select)) {
|
|
if (select[key]) picked[key] = (row as any)[key];
|
|
}
|
|
return picked;
|
|
},
|
|
update: async ({ where, data }: any) => {
|
|
boundCallLog.push({ tenantId, model: 'user', method: 'update', args: { where, data } });
|
|
const row = users.get(where.id);
|
|
if (!row || row.tenantId !== tenantId) throwNotFound();
|
|
const updated = { ...row, ...data };
|
|
users.set(where.id, updated);
|
|
return updated;
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeScopedResetToken(tenantId: string) {
|
|
return {
|
|
create: async ({ data }: any) => {
|
|
boundCallLog.push({ tenantId, model: 'passwordResetToken', method: 'create', args: { data } });
|
|
const record = { id: `rt-${resetTokens.length + 1}`, usedAt: null, ...data };
|
|
resetTokens.push(record);
|
|
return record;
|
|
},
|
|
update: async ({ where, data }: any) => {
|
|
boundCallLog.push({ tenantId, model: 'passwordResetToken', method: 'update', args: { where, data } });
|
|
const idx = resetTokens.findIndex((t) => t.id === where.id);
|
|
if (idx === -1) throwNotFound();
|
|
resetTokens[idx] = { ...resetTokens[idx], ...data };
|
|
return resetTokens[idx];
|
|
},
|
|
};
|
|
}
|
|
|
|
const fake: any = {
|
|
$queryRaw: queryRaw.fn,
|
|
__users: users,
|
|
__resetTokens: resetTokens,
|
|
__boundCallLog: boundCallLog,
|
|
__makeBoundClient(tenantId: string) {
|
|
return {
|
|
__isBoundClient: true,
|
|
__tenantId: tenantId,
|
|
user: makeScopedUser(tenantId),
|
|
passwordResetToken: makeScopedResetToken(tenantId),
|
|
};
|
|
},
|
|
};
|
|
|
|
return { prisma: fake, queryRaw };
|
|
}
|
|
|
|
function expectBoundCall(
|
|
prisma: any,
|
|
tenantId: string,
|
|
model: 'user' | 'passwordResetToken',
|
|
method: string,
|
|
) {
|
|
const found = prisma.__boundCallLog.some(
|
|
(c: BoundCall) => 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);
|
|
}
|
|
|
|
/**
|
|
* validateUser — LDAP login path (AUTH-06 follow-up): users imported from LDAP
|
|
* have no local passwordHash and must be authenticated by binding as their own
|
|
* DN against the tenant's directory. These tests cover that branch; the local
|
|
* password path (argon2) is unchanged and exercised elsewhere.
|
|
*
|
|
* Aufgabe 2 (260909-eor): validateUser sucht ab jetzt ueber
|
|
* auth_lookup_user_by_username() via $queryRaw statt this.prisma.user.findUnique
|
|
* — die Tests hier zeichnen $queryRaw statt user.findUnique auf.
|
|
*/
|
|
describe('AuthService.validateUser — LDAP login', () => {
|
|
let service: AuthService;
|
|
let prisma: any;
|
|
let ldapService: any;
|
|
let ldapConfigService: any;
|
|
let queryRaw: ReturnType<typeof fakeQueryRaw>;
|
|
|
|
const ldapUser = {
|
|
id: 'u1',
|
|
tenantId: 't1',
|
|
username: 'alice',
|
|
passwordHash: null,
|
|
ldapDn: 'CN=alice,OU=Users,DC=ctl,DC=local',
|
|
isActive: true,
|
|
role: 'USER',
|
|
displayName: null,
|
|
mustChangePassword: false,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const built = makeFakePrisma([ldapUser as FakeUserRow], [[ldapUser]]);
|
|
prisma = built.prisma;
|
|
queryRaw = built.queryRaw;
|
|
ldapService = { verifyUserCredentials: vi.fn() };
|
|
ldapConfigService = {
|
|
getConfig: vi.fn().mockResolvedValue({
|
|
serverUrl: 'ldaps://balios.ctl.local:636',
|
|
isActive: true,
|
|
tlsRejectUnauthorized: false,
|
|
}),
|
|
};
|
|
service = new AuthService(
|
|
prisma,
|
|
{} as any,
|
|
{} as any,
|
|
{} as any,
|
|
ldapService,
|
|
ldapConfigService,
|
|
);
|
|
});
|
|
|
|
it('authenticates an LDAP user via a successful directory bind', async () => {
|
|
ldapService.verifyUserCredentials.mockResolvedValue(true);
|
|
|
|
const result = await service.validateUser('Alice', 'ad-password');
|
|
|
|
expect(result).toEqual(ldapUser);
|
|
expect(ldapService.verifyUserCredentials).toHaveBeenCalledWith(
|
|
{
|
|
serverUrl: 'ldaps://balios.ctl.local:636',
|
|
tlsRejectUnauthorized: false,
|
|
},
|
|
ldapUser.ldapDn,
|
|
'ad-password',
|
|
);
|
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
|
});
|
|
|
|
it('rejects an LDAP user when the directory bind fails', async () => {
|
|
ldapService.verifyUserCredentials.mockResolvedValue(false);
|
|
|
|
const result = await service.validateUser('alice', 'wrong');
|
|
|
|
expect(result).toBeNull();
|
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects an LDAP user when no active LDAP config exists', async () => {
|
|
ldapConfigService.getConfig.mockResolvedValue(null);
|
|
|
|
const result = await service.validateUser('alice', 'pw');
|
|
|
|
expect(result).toBeNull();
|
|
expect(ldapService.verifyUserCredentials).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a passwordless user that has no ldapDn (never binds)', async () => {
|
|
const built = makeFakePrisma([{ ...ldapUser, ldapDn: null } as FakeUserRow], [[{ ...ldapUser, ldapDn: null }]]);
|
|
prisma.$queryRaw = built.queryRaw.fn;
|
|
|
|
const result = await service.validateUser('alice', 'pw');
|
|
|
|
expect(result).toBeNull();
|
|
expect(ldapService.verifyUserCredentials).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects an inactive LDAP user before any bind', async () => {
|
|
queryRaw = fakeQueryRaw([[{ ...ldapUser, isActive: false }]]);
|
|
prisma.$queryRaw = queryRaw.fn;
|
|
|
|
const result = await service.validateUser('alice', 'pw');
|
|
|
|
expect(result).toBeNull();
|
|
expect(ldapService.verifyUserCredentials).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lowercases the username before calling auth_lookup_user_by_username', async () => {
|
|
ldapService.verifyUserCredentials.mockResolvedValue(true);
|
|
|
|
await service.validateUser('Alice', 'ad-password');
|
|
|
|
expect(queryRaw.calls).toHaveLength(1);
|
|
expect(queryRaw.calls[0].values).toEqual(['alice']);
|
|
});
|
|
|
|
it('returns null (never throws) when auth_lookup_user_by_username finds nothing', async () => {
|
|
queryRaw = fakeQueryRaw([[]]);
|
|
prisma.$queryRaw = queryRaw.fn;
|
|
|
|
const result = await service.validateUser('unknown', 'pw');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('scheitert an "Cannot read properties of undefined", wenn die Suche versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => {
|
|
expect(prisma.user).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Lokaler Kennwort-Anmeldeweg (argon2) sowie requestPasswordReset/
|
|
* resetPassword — decken die Aufgabe-2-Verhaltensfaelle ab: Anmeldesuche
|
|
* findet den Benutzer weiterhin, Schreibzugriffe laufen nach gefundenem
|
|
* Benutzer mandantengebunden.
|
|
*/
|
|
describe('AuthService.validateUser — lokales Kennwort', () => {
|
|
let service: AuthService;
|
|
let prisma: any;
|
|
let queryRaw: ReturnType<typeof fakeQueryRaw>;
|
|
let localUser: {
|
|
id: string;
|
|
tenantId: string;
|
|
username: string;
|
|
passwordHash: string;
|
|
ldapDn: null;
|
|
isActive: boolean;
|
|
role: string;
|
|
displayName: null;
|
|
mustChangePassword: boolean;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
// Echter argon2-Hash statt Mock — argon2.verify laesst sich in ESM
|
|
// nicht ueber vi.spyOn ersetzen (nicht konfigurierbarer Modul-Export).
|
|
const argon2 = await import('argon2');
|
|
localUser = {
|
|
id: 'u2',
|
|
tenantId: 't1',
|
|
username: 'bob',
|
|
passwordHash: await argon2.hash('correct-password'),
|
|
ldapDn: null,
|
|
isActive: true,
|
|
role: 'USER',
|
|
displayName: null,
|
|
mustChangePassword: false,
|
|
};
|
|
|
|
const built = makeFakePrisma([localUser as FakeUserRow], [[localUser]]);
|
|
prisma = built.prisma;
|
|
queryRaw = built.queryRaw;
|
|
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
|
|
});
|
|
|
|
it('findet den Benutzer weiterhin und aktualisiert lastLoginAt mandantengebunden', async () => {
|
|
const result = await service.validateUser('bob', 'correct-password');
|
|
|
|
expect(result).toEqual(localUser);
|
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
|
const updateCall = prisma.__boundCallLog.find(
|
|
(c: BoundCall) => c.model === 'user' && c.method === 'update',
|
|
);
|
|
expect(updateCall.args).toEqual({
|
|
where: { id: 'u2' },
|
|
data: { lastLoginAt: expect.any(Date) },
|
|
});
|
|
});
|
|
|
|
it('sucht die Anmeldedaten exakt EINMAL ungebunden ueber $queryRaw und schreibt lastLoginAt exakt EINMAL gebunden unter dem Mandanten der Funktionszeile — die Grenze zwischen Anmeldeweg und Nach-Anmeldung', async () => {
|
|
await service.validateUser('bob', 'correct-password');
|
|
|
|
expect(queryRaw.calls).toHaveLength(1);
|
|
expect(vi.mocked(forTenant).mock.calls).toHaveLength(1);
|
|
expect(vi.mocked(forTenant).mock.calls[0][1]).toBe('t1');
|
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
|
});
|
|
});
|
|
|
|
describe('AuthService.requestPasswordReset', () => {
|
|
let service: AuthService;
|
|
let prisma: any;
|
|
let mailService: any;
|
|
let queryRaw: ReturnType<typeof fakeQueryRaw>;
|
|
|
|
const emailUser = {
|
|
id: 'u3',
|
|
tenantId: 't1',
|
|
email: 'bob@example.com',
|
|
isActive: true,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const built = makeFakePrisma([], [[emailUser]]);
|
|
prisma = built.prisma;
|
|
queryRaw = built.queryRaw;
|
|
mailService = { sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined) };
|
|
service = new AuthService(prisma, {} as any, {} as any, mailService, {} as any, {} as any);
|
|
});
|
|
|
|
it('legt das Rueckstell-Token mandantengebunden an, sobald der Benutzer gefunden ist', async () => {
|
|
await service.requestPasswordReset('bob@example.com');
|
|
|
|
expectBoundCall(prisma, 't1', 'passwordResetToken', 'create');
|
|
const createCall = prisma.__boundCallLog.find(
|
|
(c: BoundCall) => c.model === 'passwordResetToken' && c.method === 'create',
|
|
);
|
|
expect(createCall.args).toEqual({
|
|
data: {
|
|
token: expect.any(String),
|
|
userId: 'u3',
|
|
expiresAt: expect.any(Date),
|
|
},
|
|
});
|
|
// Drittes Argument (260914-eym): die tenantId des Empfaengers (emailUser
|
|
// liegt unter 't1') — MailService baut daraus den Transport je Versand.
|
|
expect(mailService.sendPasswordResetEmail).toHaveBeenCalledWith(
|
|
'bob@example.com',
|
|
expect.any(String),
|
|
't1',
|
|
);
|
|
});
|
|
|
|
it('kehrt bei unbekannter E-Mail wortlos zurueck (T-02-12, keine Enumeration)', async () => {
|
|
queryRaw = fakeQueryRaw([[]]);
|
|
prisma.$queryRaw = queryRaw.fn;
|
|
|
|
await service.requestPasswordReset('unknown@example.com');
|
|
|
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
|
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('AuthService.resetPassword', () => {
|
|
let service: AuthService;
|
|
let prisma: any;
|
|
let queryRaw: ReturnType<typeof fakeQueryRaw>;
|
|
|
|
const resetTokenRow = {
|
|
id: 'rt1',
|
|
token: 'a-uuid-token',
|
|
userId: 'u4',
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
usedAt: null,
|
|
tenantId: 't1',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const built = makeFakePrisma(
|
|
[
|
|
{
|
|
id: 'u4',
|
|
tenantId: 't1',
|
|
username: 'dave',
|
|
passwordHash: 'old-hash',
|
|
ldapDn: null,
|
|
isActive: true,
|
|
role: 'USER',
|
|
displayName: null,
|
|
mustChangePassword: true,
|
|
},
|
|
],
|
|
[[resetTokenRow]],
|
|
);
|
|
prisma = built.prisma;
|
|
queryRaw = built.queryRaw;
|
|
// Das Token selbst existiert im gebundenen Nachbau nicht automatisch —
|
|
// fuer den Update-Zweig genuegt hier, dass das UPDATE ueber die
|
|
// Kennung `rt1` gelingt; deshalb wird der Datensatz vorab ueber die
|
|
// Anlage nachgebildet, bevor resetPassword() ihn aktualisiert.
|
|
prisma.__resetTokens.push({ id: 'rt1', token: 'a-uuid-token', usedAt: null });
|
|
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
|
|
});
|
|
|
|
it('findet den passenden Rueckstell-Datensatz und aktualisiert Kennwort und Token mandantengebunden', async () => {
|
|
await service.resetPassword('a-uuid-token', 'new-password');
|
|
|
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
|
expectBoundCall(prisma, 't1', 'passwordResetToken', 'update');
|
|
const userUpdate = prisma.__boundCallLog.find(
|
|
(c: BoundCall) => c.model === 'user' && c.method === 'update',
|
|
);
|
|
expect(userUpdate.args).toEqual({
|
|
where: { id: 'u4' },
|
|
data: { passwordHash: expect.any(String), mustChangePassword: false },
|
|
});
|
|
const tokenUpdate = prisma.__boundCallLog.find(
|
|
(c: BoundCall) => c.model === 'passwordResetToken' && c.method === 'update',
|
|
);
|
|
expect(tokenUpdate.args).toEqual({
|
|
where: { id: 'rt1' },
|
|
data: { usedAt: expect.any(Date) },
|
|
});
|
|
});
|
|
|
|
it('wirft bei unbekanntem Token', async () => {
|
|
queryRaw = fakeQueryRaw([[]]);
|
|
prisma.$queryRaw = queryRaw.fn;
|
|
|
|
await expect(service.resetPassword('unknown', 'pw')).rejects.toThrow(
|
|
'Invalid or expired reset token',
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* getMe/changePassword/adminResetPassword — bisher OHNE einen einzigen
|
|
* Testfall (Befund F). Alle drei binden seit Aufgabe 2 an den Mandanten
|
|
* aus dem Sitzungsnachweis.
|
|
*/
|
|
describe('AuthService.getMe', () => {
|
|
let service: AuthService;
|
|
let prisma: any;
|
|
|
|
const localUserRow: FakeUserRow = {
|
|
id: 'u1',
|
|
tenantId: 't1',
|
|
username: 'alice',
|
|
passwordHash: 'a-hash',
|
|
ldapDn: null,
|
|
isActive: true,
|
|
role: 'USER',
|
|
displayName: 'Alice',
|
|
mustChangePassword: false,
|
|
avatarPath: 'avatars/u1.png',
|
|
accentColor: '#3b82f6',
|
|
// quick-260928-ujj: gespeicherter Zusatzschluessel wird bei der Ausgabe verworfen.
|
|
dashboardBackground: { kind: 'preset', id: 'dunes', extra: 'weg' },
|
|
};
|
|
|
|
const ldapUserRow: FakeUserRow = {
|
|
id: 'u2',
|
|
tenantId: 't1',
|
|
username: 'bob',
|
|
passwordHash: null,
|
|
ldapDn: 'CN=bob,OU=Users,DC=ctl,DC=local',
|
|
isActive: true,
|
|
role: 'USER',
|
|
displayName: 'Bob',
|
|
mustChangePassword: false,
|
|
avatarPath: null,
|
|
accentColor: null,
|
|
dashboardBackground: null,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const built = makeFakePrisma([localUserRow, ldapUserRow]);
|
|
prisma = built.prisma;
|
|
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
|
|
});
|
|
|
|
it('eigener Mandant, lokaler Benutzer: liefert die oeffentlichen Felder, isLocalUser true, hasAvatar true — passwordHash/ldapDn/avatarPath fehlen (T-gbh-03)', async () => {
|
|
const result = await service.getMe('t1', 'u1');
|
|
|
|
expect(result).toMatchObject({
|
|
id: 'u1',
|
|
username: 'alice',
|
|
displayName: 'Alice',
|
|
role: 'USER',
|
|
tenantId: 't1',
|
|
mustChangePassword: false,
|
|
accentColor: '#3b82f6',
|
|
dashboardBackground: { kind: 'preset', id: 'dunes' },
|
|
isLocalUser: true,
|
|
hasAvatar: true,
|
|
});
|
|
expect(result).not.toHaveProperty('passwordHash');
|
|
expect(result).not.toHaveProperty('ldapDn');
|
|
expect(result).not.toHaveProperty('avatarPath');
|
|
});
|
|
|
|
it('eigener Mandant, LDAP-Benutzer (kein Hash, ldapDn gesetzt): isLocalUser false', async () => {
|
|
const result = await service.getMe('t1', 'u2');
|
|
|
|
expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false });
|
|
});
|
|
|
|
it('quick-260928-ujj: dashboardBackground NULL (nie gewaehlt) kommt als null zurueck', async () => {
|
|
const result = await service.getMe('t1', 'u2');
|
|
|
|
expect(result).toHaveProperty('dashboardBackground', null);
|
|
});
|
|
|
|
it('quick-260928-ujj: ungueltiger gespeicherter Hintergrund kommt als null zurueck (T-ujj-01)', async () => {
|
|
prisma.__users.set('u1', {
|
|
...prisma.__users.get('u1'),
|
|
dashboardBackground: { kind: 'image', imageId: '") ; background: url("x' },
|
|
});
|
|
|
|
const result = await service.getMe('t1', 'u1');
|
|
|
|
expect(result).toHaveProperty('dashboardBackground', null);
|
|
});
|
|
|
|
it('quick-260928-ujj: dashboardBackground steht im select neben accentColor', async () => {
|
|
await service.getMe('t1', 'u1');
|
|
|
|
const call = prisma.__boundCallLog.find((c: any) => c.method === 'findUnique');
|
|
expect(call.args.select).toMatchObject({ accentColor: true, dashboardBackground: true });
|
|
});
|
|
|
|
it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => {
|
|
const result = await service.getMe('t2', 'u1');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => {
|
|
vi.mocked(forTenant).mockClear();
|
|
|
|
await service.getMe('t1', 'u1');
|
|
|
|
expect(vi.mocked(forTenant).mock.calls).toHaveLength(1);
|
|
expect(vi.mocked(forTenant).mock.calls[0][1]).toBe('t1');
|
|
});
|
|
});
|
|
|
|
describe('AuthService.changePassword', () => {
|
|
let service: AuthService;
|
|
let prisma: any;
|
|
let jwtService: any;
|
|
let configService: any;
|
|
let response: any;
|
|
let localUserRow: FakeUserRow;
|
|
let ldapUserRow: FakeUserRow;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const argon2 = await import('argon2');
|
|
localUserRow = {
|
|
id: 'u1',
|
|
tenantId: 't1',
|
|
username: 'alice',
|
|
passwordHash: await argon2.hash('current-password'),
|
|
ldapDn: null,
|
|
isActive: true,
|
|
role: 'USER',
|
|
displayName: 'Alice',
|
|
mustChangePassword: false,
|
|
};
|
|
ldapUserRow = {
|
|
id: 'u2',
|
|
tenantId: 't1',
|
|
username: 'bob',
|
|
passwordHash: null,
|
|
ldapDn: 'CN=bob,OU=Users,DC=ctl,DC=local',
|
|
isActive: true,
|
|
role: 'USER',
|
|
displayName: 'Bob',
|
|
mustChangePassword: false,
|
|
};
|
|
const built = makeFakePrisma([localUserRow, ldapUserRow]);
|
|
prisma = built.prisma;
|
|
jwtService = { sign: vi.fn().mockReturnValue('signed.jwt.token') };
|
|
configService = { get: vi.fn().mockReturnValue('development') };
|
|
response = { cookie: vi.fn() };
|
|
service = new AuthService(prisma, jwtService, configService, {} as any, {} as any, {} as any);
|
|
});
|
|
|
|
it('eigener Mandant, richtiges aktuelles Kennwort: gebundenes update traegt neuen Hash und mustChangePassword=false, JWT signiert mit tenantId/mustChangePassword, Cookie gesetzt', async () => {
|
|
const argon2 = await import('argon2');
|
|
|
|
await service.changePassword('t1', 'u1', 'current-password', 'brand-new-password', response);
|
|
|
|
const updateCall = prisma.__boundCallLog.find(
|
|
(c: BoundCall) => c.model === 'user' && c.method === 'update',
|
|
);
|
|
expect(updateCall).toBeDefined();
|
|
expect(updateCall.args.where).toEqual({ id: 'u1' });
|
|
expect(updateCall.args.data.mustChangePassword).toBe(false);
|
|
const isNewHashValid = await argon2.verify(
|
|
updateCall.args.data.passwordHash,
|
|
'brand-new-password',
|
|
);
|
|
expect(isNewHashValid).toBe(true);
|
|
|
|
expect(jwtService.sign).toHaveBeenCalledWith(
|
|
expect.objectContaining({ tenantId: 't1', mustChangePassword: false }),
|
|
);
|
|
expect(response.cookie).toHaveBeenCalledWith(
|
|
'session',
|
|
'signed.jwt.token',
|
|
expect.objectContaining({ httpOnly: true }),
|
|
);
|
|
});
|
|
|
|
it('FREMDER Mandant: UnauthorizedException, kein Schreibzugriff, kein Cookie', async () => {
|
|
await expect(
|
|
service.changePassword('t2', 'u1', 'current-password', 'new-password', response),
|
|
).rejects.toThrow('User not found or has no local password');
|
|
|
|
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
|
|
expect(response.cookie).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falsches aktuelles Kennwort: Current password is incorrect, kein Schreibzugriff', async () => {
|
|
await expect(
|
|
service.changePassword('t1', 'u1', 'wrong-password', 'new-password', response),
|
|
).rejects.toThrow('Current password is incorrect');
|
|
|
|
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
|
|
});
|
|
|
|
it('LDAP-Benutzer (kein Hash): UnauthorizedException, kein Schreibzugriff', async () => {
|
|
await expect(
|
|
service.changePassword('t1', 'u2', 'anything', 'new-password', response),
|
|
).rejects.toThrow('User not found or has no local password');
|
|
|
|
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
|
|
});
|
|
|
|
it('erzeugt genau EINEN gebundenen Klienten je Aufruf (Suche und Schreiben auf demselben)', async () => {
|
|
vi.mocked(forTenant).mockClear();
|
|
|
|
await service.changePassword('t1', 'u1', 'current-password', 'new-password', response);
|
|
|
|
expect(vi.mocked(forTenant).mock.calls).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('AuthService.adminResetPassword', () => {
|
|
let service: AuthService;
|
|
let prisma: any;
|
|
let targetUserRow: FakeUserRow;
|
|
let superAdminTargetRow: FakeUserRow;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
targetUserRow = {
|
|
id: 'target',
|
|
tenantId: 't1',
|
|
username: 'carol',
|
|
passwordHash: 'old-hash',
|
|
ldapDn: null,
|
|
isActive: true,
|
|
role: 'USER',
|
|
displayName: 'Carol',
|
|
mustChangePassword: false,
|
|
};
|
|
superAdminTargetRow = {
|
|
id: 'boss',
|
|
tenantId: 't1',
|
|
username: 'dora',
|
|
passwordHash: 'old-hash',
|
|
ldapDn: null,
|
|
isActive: true,
|
|
role: 'SUPER_ADMIN',
|
|
displayName: 'Dora',
|
|
mustChangePassword: false,
|
|
};
|
|
const built = makeFakePrisma([targetUserRow, superAdminTargetRow]);
|
|
prisma = built.prisma;
|
|
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
|
|
});
|
|
|
|
it('eigener Mandant, Aufrufer ADMIN, Ziel USER: gebundenes update mit neuem Hash, mustChangePassword TRUE (Vorgabe, Parameter weggelassen)', async () => {
|
|
const argon2 = await import('argon2');
|
|
|
|
await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password');
|
|
|
|
const updateCall = prisma.__boundCallLog.find(
|
|
(c: BoundCall) => c.model === 'user' && c.method === 'update',
|
|
);
|
|
expect(updateCall.args.where).toEqual({ id: 'target' });
|
|
expect(updateCall.args.data.mustChangePassword).toBe(true);
|
|
const ok = await argon2.verify(updateCall.args.data.passwordHash, 'fresh-password');
|
|
expect(ok).toBe(true);
|
|
});
|
|
|
|
it('mustChangePassword: false wird durchgereicht', async () => {
|
|
await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password', false);
|
|
|
|
const updateCall = prisma.__boundCallLog.find(
|
|
(c: BoundCall) => c.model === 'user' && c.method === 'update',
|
|
);
|
|
expect(updateCall.args.data.mustChangePassword).toBe(false);
|
|
});
|
|
|
|
it('FREMDER Mandant: BadRequestException, Meldung woertlich "User not found", KEIN Schreibzugriff — nennt weder Halter noch Mandanten', async () => {
|
|
await expect(
|
|
service.adminResetPassword('t2', Role.ADMIN, 'target', 'fresh-password'),
|
|
).rejects.toThrow('User not found');
|
|
|
|
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
|
|
});
|
|
|
|
it('Aufrufer ADMIN, Ziel SUPER_ADMIN im SELBEN Mandanten: ForbiddenException (T-FH9-04), KEIN Schreibzugriff', async () => {
|
|
await expect(
|
|
service.adminResetPassword('t1', Role.ADMIN, 'boss', 'fresh-password'),
|
|
).rejects.toThrow(); // ForbiddenException
|
|
|
|
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
|
|
});
|
|
|
|
it('Aufrufer SUPER_ADMIN, Ziel SUPER_ADMIN: gelingt', async () => {
|
|
await service.adminResetPassword('t1', Role.SUPER_ADMIN, 'boss', 'fresh-password');
|
|
|
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
|
});
|
|
|
|
it('erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => {
|
|
vi.mocked(forTenant).mockClear();
|
|
|
|
await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password');
|
|
|
|
expect(vi.mocked(forTenant).mock.calls).toHaveLength(1);
|
|
});
|
|
});
|