Integrations such as the Wabenhain shop sync no longer need a service account with a password (and without MFA). An admin creates a key under Verwaltung > Automatisierung > API-Schlüssel. - Key format ack_<8>_<43>; only its SHA-256 hash is stored, the key is shown exactly once. Optional expiry, "last used" timestamp, revocation takes effect immediately; create/revoke are audit-logged without the key. - A key is not a user: JwtAuthGuard sets request.apiKey, PermissionsGuard admits it only to routes whose permission is among its scopes and never to user-only routes. Scopes come from a short allowlist (shop-sync:read). - Managing keys requires the new permission api-keys:manage. - Keys are excluded from backups on purpose; a restore means issuing a new key. Additive migration (new table). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
194 lines
5.8 KiB
TypeScript
194 lines
5.8 KiB
TypeScript
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<App>;
|
|
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<string, unknown>) {
|
|
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('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);
|
|
});
|
|
});
|