Files
ApiCore/ApiCore-Backend/test/api-keys.e2e-spec.ts
T

248 lines
7.7 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('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);
});
});