feat(quick-260909-eor): schmale SECURITY-DEFINER-Ausnahme fuer den Anmeldeweg
WINDOWS #18/#20, Aufgabe 2: der Anmeldeweg muss den passenden Benutzer finden, bevor sein Mandant bekannt ist — unter der kuenftigen Rolle ohne BYPASSRLS (tessera_app) wuerde ein gewoehnlicher SELECT auf "User" sonst null Zeilen liefern und die Anmeldung waere unmoeglich. Drei SECURITY-DEFINER-Funktionen (STABLE, fester Suchpfad public/pg_temp, fester Spaltensatz, LIMIT 1, Ausfuehrungsrecht ausschliesslich fuer tessera_app) ersetzen die drei pre-tenant Lesezugriffe in auth.service.ts: - auth_lookup_user_by_username (validateUser) - auth_lookup_user_by_email (requestPasswordReset) - auth_lookup_reset_token (resetPassword) Sobald der Benutzer und damit sein Mandant bekannt sind, laufen alle Schreibzugriffe (lastLoginAt, passwordHash, Reset-Token) ueber forTenant(), gebunden an genau diesen Mandanten (Aufgabe 1). getMe/changePassword/ adminResetPassword bleiben bewusst unangetastet — sie kennen den Mandanten bereits aus dem Sitzungsnachweis und gehoeren in Etappe 2. rls-scratch-check.mjs um einen zweiten Abschnitt erweitert: spielt die Migration in die Wegwerf-Datenbank ein und misst live unter der Rolle ohne BYPASSRLS — Anmeldesuche findet den Benutzer, unbekannter Name liefert nichts ohne zu werfen, gewoehnlicher SELECT auf "User" liefert null Zeilen. Alle 8 Pruefungen (5 aus Aufgabe 1 + 3 neue) bestehen gegen die lokale Datenbank. Volle Testsuite (695 Tests) und type-check bleiben gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
@@ -1,17 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
// forTenant() gibt in diesen Tests denselben Client zurueck (tenant scoping
|
||||
// ist hier nicht die Pruefung) — dasselbe Muster wie in
|
||||
// ldap.service.spec.ts.
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
@@ -24,9 +53,10 @@ describe('AuthService.validateUser — LDAP login', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryRaw = fakeQueryRaw([[ldapUser]]);
|
||||
prisma = {
|
||||
$queryRaw: queryRaw.fn,
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue(ldapUser),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
@@ -87,10 +117,8 @@ describe('AuthService.validateUser — LDAP login', () => {
|
||||
});
|
||||
|
||||
it('rejects a passwordless user that has no ldapDn (never binds)', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({
|
||||
...ldapUser,
|
||||
ldapDn: null,
|
||||
});
|
||||
queryRaw = fakeQueryRaw([[{ ...ldapUser, ldapDn: null }]]);
|
||||
prisma.$queryRaw = queryRaw.fn;
|
||||
|
||||
const result = await service.validateUser('alice', 'pw');
|
||||
|
||||
@@ -99,11 +127,176 @@ describe('AuthService.validateUser — LDAP login', () => {
|
||||
});
|
||||
|
||||
it('rejects an inactive LDAP user before any bind', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({ ...ldapUser, isActive: false });
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
queryRaw = fakeQueryRaw([[localUser]]);
|
||||
prisma = {
|
||||
$queryRaw: queryRaw.fn,
|
||||
user: { update: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
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);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'u2' },
|
||||
data: { lastLoginAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
queryRaw = fakeQueryRaw([[emailUser]]);
|
||||
prisma = {
|
||||
$queryRaw: queryRaw.fn,
|
||||
passwordResetToken: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
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');
|
||||
|
||||
expect(prisma.passwordResetToken.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
token: expect.any(String),
|
||||
userId: 'u3',
|
||||
expiresAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
expect(mailService.sendPasswordResetEmail).toHaveBeenCalledWith(
|
||||
'bob@example.com',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
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.passwordResetToken.create).not.toHaveBeenCalled();
|
||||
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();
|
||||
queryRaw = fakeQueryRaw([[resetTokenRow]]);
|
||||
prisma = {
|
||||
$queryRaw: queryRaw.fn,
|
||||
user: { update: vi.fn().mockResolvedValue({}) },
|
||||
passwordResetToken: { update: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
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');
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'u4' },
|
||||
data: { passwordHash: expect.any(String), mustChangePassword: false },
|
||||
});
|
||||
expect(prisma.passwordResetToken.update).toHaveBeenCalledWith({
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user