import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; interface CreatedKeyBody { apiKey: { id: string; name: string; prefix: string; scopes: string[]; revokedAt: string | null; }; token: string; } /** * Read-only API keys for machine access (the shop sync): shown once, stored * only as a hash, limited to their scopes, revocable. */ describe('API keys (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; let adminToken: string; let staffToken: string; const server = () => app.getHttpServer(); function createKey(body: Record) { return request(server()) .post('/api/v1/api-keys') .set('Authorization', `Bearer ${adminToken}`) .send(body); } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); await prisma.user.create({ data: { email: `keys-admin-${suffix}@example.com`, passwordHash, name: 'Admin', role: { connect: { key: 'ADMIN' } }, }, }); await prisma.user.create({ data: { email: `keys-staff-${suffix}@example.com`, passwordHash, name: 'Staff', role: { connect: { key: 'STAFF' } }, }, }); adminToken = ( await login(app, `keys-admin-${suffix}@example.com`, password) ).accessToken; staffToken = ( await login(app, `keys-staff-${suffix}@example.com`, password) ).accessToken; }); afterAll(async () => { await app.close(); }); it('shows the key once and stores only a hash', async () => { const response = await createKey({ name: `Wabenhain ${suffix}`, scopes: ['shop-sync:read'], }).expect(201); const body = response.body as CreatedKeyBody; expect(body.token).toMatch(/^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/); expect(body.token.startsWith(body.apiKey.prefix)).toBe(true); expect(JSON.stringify(body.apiKey)).not.toContain(body.token); const stored = await prisma.apiKey.findUniqueOrThrow({ where: { id: body.apiKey.id }, }); expect(stored.keyHash).not.toContain(body.token); const list = await request(server()) .get('/api/v1/api-keys') .set('Authorization', `Bearer ${adminToken}`) .expect(200); const raw = JSON.stringify(list.body); expect(raw).not.toContain(body.token); expect(raw).not.toContain('keyHash'); }); it('refuses a new key with both shop scopes', async () => { const response = await createKey({ name: `Combined ${suffix}`, scopes: ['shop-sync:read', 'shop-sync:write'], }).expect(400); expect(JSON.stringify(response.body)).toContain('getrennte Schlüssel'); }); it('refuses a new key with more than one scope', async () => { const response = await createKey({ name: `Two reads ${suffix}`, scopes: ['shop-sync:read', 'erp-inventory:read'], }).expect(400); expect(JSON.stringify(response.body)).toContain('nur eine Berechtigung'); }); it('lets a key read exactly what its scopes allow', async () => { const { token } = ( await createKey({ name: `Scope ${suffix}`, scopes: ['shop-sync:read'] }) ).body as CreatedKeyBody; await request(server()) .get('/api/v1/shop-sync') .set('Authorization', `Bearer ${token}`) .expect(200); // Not a user: no other data, no user-only routes. await request(server()) .get('/api/v1/harvests') .set('Authorization', `Bearer ${token}`) .expect(403); await request(server()) .get('/api/v1/auth/me') .set('Authorization', `Bearer ${token}`) .expect(403); }); it('records when a key was last used', async () => { const created = ( await createKey({ name: `Used ${suffix}`, scopes: ['shop-sync:read'] }) ).body as CreatedKeyBody; await request(server()) .get('/api/v1/shop-sync') .set('Authorization', `Bearer ${created.token}`) .expect(200); const stored = await prisma.apiKey.findUniqueOrThrow({ where: { id: created.apiKey.id }, }); expect(stored.lastUsedAt).not.toBeNull(); }); it('stops working immediately once revoked', async () => { const created = ( await createKey({ name: `Revoke ${suffix}`, scopes: ['shop-sync:read'] }) ).body as CreatedKeyBody; await request(server()) .post(`/api/v1/api-keys/${created.apiKey.id}/revoke`) .set('Authorization', `Bearer ${adminToken}`) .expect(201); await request(server()) .get('/api/v1/shop-sync') .set('Authorization', `Bearer ${created.token}`) .expect(401); }); it('rejects an expired key', async () => { const created = ( await createKey({ name: `Expired ${suffix}`, scopes: ['shop-sync:read'], expiresInDays: 1, }) ).body as CreatedKeyBody; await prisma.apiKey.update({ where: { id: created.apiKey.id }, data: { expiresAt: new Date(Date.now() - 1000) }, }); await request(server()) .get('/api/v1/shop-sync') .set('Authorization', `Bearer ${created.token}`) .expect(401); }); it('rejects unknown and tampered keys', async () => { const created = ( await createKey({ name: `Tamper ${suffix}`, scopes: ['shop-sync:read'] }) ).body as CreatedKeyBody; const tampered = `${created.token.slice(0, 20)}${created.token[20] === 'A' ? 'B' : 'A'}${created.token.slice(21)}`; await request(server()) .get('/api/v1/shop-sync') .set('Authorization', `Bearer ${tampered}`) .expect(401); await request(server()) .get('/api/v1/shop-sync') .set('Authorization', 'Bearer ack_nope') .expect(401); }); it('only allows scopes released for keys', async () => { await createKey({ name: `Too much ${suffix}`, scopes: ['harvests:read'], }).expect(400); }); it('can only be managed with api-keys:manage', async () => { await request(server()) .post('/api/v1/api-keys') .set('Authorization', `Bearer ${staffToken}`) .send({ name: 'x', scopes: ['shop-sync:read'] }) .expect(403); }); it('works only from allowed addresses and records where it was used', async () => { const elsewhere = ( await createKey({ name: `Elsewhere ${suffix}`, scopes: ['shop-sync:read'], allowedIps: ['203.0.113.7'], }).expect(201) ).body as CreatedKeyBody; await request(server()) .get('/api/v1/shop-sync') .set('Authorization', `Bearer ${elsewhere.token}`) .expect(403); // Allow loopback (where this test runs) afterwards, without a new key. await request(server()) .patch(`/api/v1/api-keys/${elsewhere.apiKey.id}`) .set('Authorization', `Bearer ${adminToken}`) .send({ allowedIps: ['127.0.0.0/8', '::1'] }) .expect(200); await request(server()) .get('/api/v1/shop-sync') .set('Authorization', `Bearer ${elsewhere.token}`) .expect(200); const stored = await prisma.apiKey.findUniqueOrThrow({ where: { id: elsewhere.apiKey.id }, }); expect(stored.lastUsedIp).toMatch(/127\.0\.0\.1|::1/); }); it('refuses an allowlist entry that is not an address or network', async () => { await createKey({ name: `Bad allowlist ${suffix}`, scopes: ['shop-sync:read'], allowedIps: ['example.com'], }).expect(400); }); });