import { BadRequestException, ForbiddenException, HttpException, Logger, } from '@nestjs/common'; import { createHash } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage'; import { ApiKeysService, extractApiKeyToken } from './api-keys.service'; import { API_KEY_FAILURE_LIMIT, FailedAttemptLimiter, } from './failed-attempt-limiter'; // Synthetischer Schlüssel im echten Format - kein echter Zugang. const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`; const OTHER_TOKEN = `ack_zzzz9999_${'B'.repeat(43)}`; function storedKey(overrides: Record = {}) { return { id: 'key-1', name: 'ERPNext', prefix: 'ack_abcd1234', keyHash: createHash('sha256').update(TOKEN).digest('hex'), scopes: ['erp-inventory:read'], createdByUserId: 'user-1', createdAt: new Date('2026-10-01T00:00:00.000Z'), lastUsedAt: null, lastUsedIp: null, allowedIps: [], expiresAt: null, revokedAt: null, ...overrides, }; } /** * Stand-in for the shared counter table with the same counting rule as * PrismaThrottlerStorage within one window: count up to the limit, the hit * after it blocks, a blocked source keeps its count. Time and the database * itself are covered by the e2e tests. */ function inMemoryStorage(): PrismaThrottlerStorage { const rows = new Map(); return { increment: ( key: string, ttl: number, limit: number, blockDuration: number, throttlerName: string, ) => { const id = `${throttlerName}|${key}`; const row = rows.get(id) ?? { hits: 0, blocked: false }; const next = row.blocked ? row : { hits: row.hits + 1, blocked: row.hits + 1 > limit }; rows.set(id, next); return Promise.resolve({ totalHits: next.hits, timeToExpire: ttl / 1000, isBlocked: next.blocked, timeToBlockExpire: next.blocked ? blockDuration / 1000 : 0, }); }, } as unknown as PrismaThrottlerStorage; } describe('ApiKeysService.authenticate', () => { let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } }; let service: ApiKeysService; let warn: jest.SpyInstance; beforeEach(() => { prisma = { apiKey: { findUnique: jest.fn().mockResolvedValue(null), update: jest.fn().mockResolvedValue({}), }, }; service = new ApiKeysService( prisma as unknown as PrismaService, { record: jest.fn() } as unknown as AuditLogService, { getPermissions: jest.fn() } as unknown as PermissionResolverService, new FailedAttemptLimiter(inMemoryStorage()), ); warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); }); afterEach(() => { warn.mockRestore(); }); function loggedText(): string { return JSON.stringify(warn.mock.calls); } it('sucht den Schlüssel nur über seinen Hash', async () => { prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey()); const principal = await service.authenticate(TOKEN, '203.0.113.7'); expect(principal).toEqual({ id: 'key-1', scopes: ['erp-inventory:read'] }); expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({ where: { keyHash: createHash('sha256').update(TOKEN).digest('hex') }, }); }); it('lehnt einen fehlerhaft aufgebauten Schlüssel ohne Datenbankzugriff ab', async () => { await expect(service.authenticate('ack_kurz', '203.0.113.7')).resolves.toBe( null, ); expect(prisma.apiKey.findUnique).not.toHaveBeenCalled(); }); it('lehnt einen unbekannten Schlüssel ab', async () => { await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe( null, ); }); it('lehnt einen widerrufenen (gesperrten) Schlüssel ab', async () => { prisma.apiKey.findUnique.mockResolvedValueOnce( storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }), ); await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe( null, ); expect(loggedText()).toContain('revoked'); }); it('lehnt einen abgelaufenen Schlüssel ab', async () => { prisma.apiKey.findUnique.mockResolvedValueOnce( storedKey({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }), ); await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe( null, ); expect(loggedText()).toContain('expired'); }); it('schreibt nie den Schlüssel selbst ins Log, nur das Präfix', async () => { prisma.apiKey.findUnique.mockResolvedValueOnce( storedKey({ revokedAt: new Date() }), ); await service.authenticate(TOKEN, '203.0.113.7'); await service.authenticate(OTHER_TOKEN, '203.0.113.7'); const text = loggedText(); expect(text).not.toContain(TOKEN); expect(text).not.toContain(OTHER_TOKEN); expect(text).not.toContain('A'.repeat(43)); expect(text).not.toContain('B'.repeat(43)); expect(text).toContain('ack_abcd1234'); }); it('gibt nur Rechte weiter, die für Schlüssel freigegeben sind', async () => { prisma.apiKey.findUnique.mockResolvedValueOnce( storedKey({ scopes: ['erp-inventory:read', 'users:create'] }), ); const principal = await service.authenticate(TOKEN, '203.0.113.7'); expect(principal?.scopes).toEqual(['erp-inventory:read']); }); it('weist einen richtigen Schlüssel von einer nicht freigegebenen Adresse mit 403 ab', async () => { prisma.apiKey.findUnique.mockResolvedValueOnce( storedKey({ allowedIps: ['198.51.100.0/24'] }), ); await expect( service.authenticate(TOKEN, '203.0.113.7'), ).rejects.toBeInstanceOf(ForbiddenException); }); async function failFrom(ip: string, times: number): Promise { for (let i = 0; i < times; i++) { // Unbekannter, aber richtig aufgebauter Schlüssel. await expect(service.authenticate(OTHER_TOKEN, ip)).resolves.toBe(null); } } it(`sperrt unbekannte Schlüssel einer Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429`, async () => { await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures); const blocked = service.authenticate(OTHER_TOKEN, '203.0.113.7'); await expect(blocked).rejects.toBeInstanceOf(HttpException); await expect( service.authenticate(OTHER_TOKEN, '203.0.113.7'), ).rejects.toMatchObject({ status: 429 }); expect(loggedText()).toContain('203.0.113.7'); // Widerrufene Schlüssel sind ebenfalls ungültig und bleiben gesperrt. prisma.apiKey.findUnique.mockResolvedValueOnce( storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }), ); await expect( service.authenticate(TOKEN, '203.0.113.7'), ).rejects.toMatchObject({ status: 429 }); // Andere Adressen sind nicht betroffen. await expect( service.authenticate(OTHER_TOKEN, '198.51.100.1'), ).resolves.toBe(null); }); it('meldet das Erreichen der Grenze einmal im Log', async () => { await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures); await expect( service.authenticate(OTHER_TOKEN, '203.0.113.7'), ).rejects.toMatchObject({ status: 429 }); const limitMessages = warn.mock.calls.filter(([message]) => String(message).includes('get 429'), ); expect(limitMessages).toHaveLength(1); }); it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => { await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures); await expect( service.authenticate(OTHER_TOKEN, '203.0.113.7'), ).rejects.toMatchObject({ status: 429 }); prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey()); await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toEqual({ id: 'key-1', scopes: ['erp-inventory:read'], }); }); it('zählt fehlerhaft aufgebaute Schlüssel nicht als Fehlversuch', async () => { for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures * 2; i++) { await expect(service.authenticate('ack_x', '203.0.113.7')).resolves.toBe( null, ); } // Weder gesperrt noch eine Datenbankabfrage. expect(prisma.apiKey.findUnique).not.toHaveBeenCalled(); await expect( service.authenticate(OTHER_TOKEN, '203.0.113.7'), ).resolves.toBe(null); }); it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => { // Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht. for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) { await service.authenticate( OTHER_TOKEN, `2001:db8:1:2::${i.toString(16)}`, ); } await expect( service.authenticate(OTHER_TOKEN, '2001:db8:1:2::ffff'), ).rejects.toMatchObject({ status: 429 }); // Ein anderes /64 ist frei. await expect( service.authenticate(OTHER_TOKEN, '2001:db8:1:3::1'), ).resolves.toBe(null); }); }); describe('ApiKeysService.create', () => { let service: ApiKeysService; let prisma: { apiKey: { create: jest.Mock } }; beforeEach(() => { prisma = { apiKey: { create: jest.fn((args: { data: Record }) => Promise.resolve({ id: 'key-1', keyHash: 'h', ...args.data }), ), }, }; const permissionResolver = { getPermissions: jest .fn() .mockResolvedValue( new Set(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read']), ), }; service = new ApiKeysService( prisma as unknown as PrismaService, { record: jest.fn() } as unknown as AuditLogService, permissionResolver as unknown as PermissionResolverService, new FailedAttemptLimiter(inMemoryStorage()), ); }); it('refuses a new key with both shop scopes', async () => { await expect( service.create( { name: 'Shop', scopes: ['shop-sync:read', 'shop-sync:write'], }, 'user-1', ), ).rejects.toThrow( new BadRequestException( 'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen', ), ); expect(prisma.apiKey.create).not.toHaveBeenCalled(); }); it('refuses the read-only ERP scope together with booking', async () => { await expect( service.create( { name: 'ERPNext', scopes: ['erp-inventory:read', 'shop-sync:write'], }, 'user-1', ), ).rejects.toThrow( new BadRequestException( 'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen', ), ); expect(prisma.apiKey.create).not.toHaveBeenCalled(); }); it('refuses a new key with more than one scope, even if all of them read', async () => { await expect( service.create( { name: 'Alles', scopes: ['shop-sync:read', 'erp-inventory:read'] }, 'user-1', ), ).rejects.toThrow( new BadRequestException( 'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen', ), ); expect(prisma.apiKey.create).not.toHaveBeenCalled(); }); it('treats the same scope given twice as one', async () => { const created = await service.create( { name: 'ERPNext', scopes: ['erp-inventory:read', 'erp-inventory:read'] }, 'user-1', ); expect(created.apiKey.scopes).toEqual(['erp-inventory:read']); }); it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])( 'still creates a key with only %s', async (scope) => { const created = await service.create( { name: 'Shop', scopes: [scope] }, 'user-1', ); expect(created.apiKey.scopes).toEqual([scope]); }, ); }); describe('extractApiKeyToken', () => { it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => { expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN); expect(extractApiKeyToken('Bearer eyJhbGciOi.jwt')).toBeNull(); expect(extractApiKeyToken(undefined)).toBeNull(); }); });