Merge main checkpoint c01a388 into feat/erp-inventar-export

Brings the ERP inventory export up to date with the platform backbone and
field capture work. Conflicts in ARCHITECTURE.md, the API key service and
the API key settings tab resolved by keeping both sides:
- read and write keys stay separate; erp-inventory:read is read-only and
  cannot be combined with a write scope
- the failed-attempt lockout counts in the shared ThrottlerHit table so it
  holds across API pods
- the UI offers only scopes the user holds and creates one scope per key

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 22:49:53 +02:00
commit 53fab102f2
347 files changed
+26087 -2141

No files matched your search

@@ -1,13 +1,19 @@
import { ForbiddenException, HttpException, Logger } from '@nestjs/common';
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,
ApiKeysService,
extractApiKeyToken,
} from './api-keys.service';
FailedAttemptLimiter,
} from './failed-attempt-limiter';
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
@@ -31,6 +37,38 @@ function storedKey(overrides: Record<string, unknown> = {}) {
};
}
/**
* 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<string, { hits: number; blocked: boolean }>();
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;
@@ -47,6 +85,7 @@ describe('ApiKeysService.authenticate', () => {
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(() => {});
});
@@ -166,6 +205,18 @@ describe('ApiKeysService.authenticate', () => {
).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(
@@ -210,6 +261,79 @@ describe('ApiKeysService.authenticate', () => {
});
});
describe('ApiKeysService.create', () => {
let service: ApiKeysService;
let prisma: { apiKey: { create: jest.Mock } };
beforeEach(() => {
prisma = {
apiKey: {
create: jest.fn((args: { data: Record<string, unknown> }) =>
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.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);