fix(erp-export): strict seit format, DB clock for settle window, movement index, 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 23:24:05 +02:00
1 parent 53fab102f2
commit 25d95b0473
19 files changed
+426 -57

No files matched your search

@@ -322,6 +322,28 @@ describe('ApiKeysService.create', () => {
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) => {
@@ -104,14 +104,19 @@ export class ApiKeysService {
dto: CreateApiKeyDto,
actingUserId: string,
): Promise<CreatedApiKey> {
// Reading and booking are separate keys: a leaked read key cannot book,
// a leaked booking key cannot read. Existing combined keys stay valid.
// erp-inventory:read is a read scope too.
// One scope per new key: a leaked read key cannot book, a leaked booking
// key cannot read, and each connected system gets its own key.
// Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14).
if (combinesReadAndWrite(dto.scopes)) {
throw new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
);
}
if (new Set(dto.scopes).size > 1) {
throw new BadRequestException(
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
);
}
// Nobody hands a machine more than they may do themselves.
const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId);
@@ -50,21 +50,46 @@ describe('ERP-Abfrageparameter', () => {
},
);
it('nimmt einen ISO-Zeitpunkt für seit an', async () => {
expect(
await errorsOf(ErpMovementsQueryDto, { seit: '2026-01-01T00:00:00Z' }),
).toEqual([]);
expect(
await errorsOf(ErpMovementsQueryDto, { seit: '2026-01-01' }),
).toEqual([]);
it.each([
'2026-01-01',
'2026-01-01T00:00Z',
'2026-01-01T00:00:00Z',
'2026-01-01T00:00:00.123Z',
'2026-01-01T01:00:00+01:00',
'2025-12-31T22:30:00.5-02:30',
'2000-01-01',
'2100-12-31T23:59:59Z',
])('nimmt seit=%s an', async (seit) => {
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual([]);
});
it.each(['gestern', '2026-13-01', '01.01.2026'])(
'lehnt seit=%s ab',
async (seit) => {
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual(['seit']);
},
);
it.each([
'gestern',
'2026-13-01',
'01.01.2026',
// Andere ISO-8601-Formen: führten zu 500 oder wurden falsch gelesen.
'2026-W01',
'20260101',
'-2026-01-01',
'+002026-01-01',
'2026-001',
// Uhrzeit ohne Zone wäre Ortszeit des Servers.
'2026-01-01T00:00',
'2026-01-01T24:00',
'2026-01-01T24:00Z',
'2026-01-01T23:60Z',
'2026-01-01T23:59:60Z',
'2026-01-01T00:00:00+24:00',
'2026-01-01 00:00:00Z',
'2026-02-30',
// Gleiche Jahresgrenzen wie der Cursor: sonst 500 in der Datenbank.
'1999-12-31',
'2101-01-01',
'2100-12-31T23:00:00-02:00',
'9999-12-31T00:00:00Z',
])('lehnt seit=%s ab', async (seit) => {
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual(['seit']);
});
it.each(['1899', '2101', '2026.5', 'dieses'])(
'lehnt jahr=%s ab',
@@ -2,14 +2,20 @@ import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsISO8601,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
ValidateBy,
} from 'class-validator';
import {
ERP_MAX_YEAR,
ERP_MIN_YEAR,
parseSeit,
SEIT_PATTERN,
} from '../erp-time';
export const DEFAULT_PAGE_SIZE = 100;
export const MAX_PAGE_SIZE = 500;
@@ -45,12 +51,24 @@ export class ErpPageQueryDto {
export class ErpMovementsQueryDto extends ErpPageQueryDto {
@ApiPropertyOptional({
description:
'Nur Bewegungen ab diesem Zeitpunkt (ISO 8601, einschließlich; UTC mit "Z" empfohlen). Für den ersten Abruf; danach mit "nach" weiterlesen. Nicht zusammen mit "nach".',
description: `Nur Bewegungen ab diesem Zeitpunkt (einschließlich): JJJJ-MM-TT (Mitternacht UTC) oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm, Jahr ${ERP_MIN_YEAR}–${ERP_MAX_YEAR}. Für den ersten Abruf; danach mit "nach" weiterlesen. Nicht zusammen mit "nach".`,
example: '2026-01-01T00:00:00Z',
})
@IsOptional()
@IsISO8601({ strict: true })
@IsString()
@Matches(SEIT_PATTERN, {
message:
'seit muss JJJJ-MM-TT oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm sein',
})
@ValidateBy({
name: 'isErpSeit',
validator: {
validate: (value: unknown) =>
typeof value === 'string' && parseSeit(value) !== null,
defaultMessage: () =>
`seit muss ein gültiger Zeitpunkt in den Jahren ${ERP_MIN_YEAR} bis ${ERP_MAX_YEAR} sein`,
},
})
seit?: string;
}
+3 -10
View File
@@ -1,4 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { isPlausibleErpDate } from './erp-time';
/**
* Blätter-Cursor des ERP-Exports: die Position hinter dem letzten gelieferten
@@ -23,11 +24,6 @@ type CursorOf<K extends ErpCursorKind> = Extract<ErpCursor, { k: K }>;
// etwas anderes bedeuten könnten.
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
// Zeitstempel eines Bewegungs-Cursors: nur plausible Jahre. JavaScript
// kennt Jahre bis +275760, die Datenbankabfrage nicht - das wäre ein 500.
const MIN_CURSOR_YEAR = 2000;
const MAX_CURSOR_YEAR = 2100;
const FIELDS: Record<ErpCursorKind, readonly string[]> = {
produkte: ['id', 'k'],
chargen: ['id', 'k', 't'],
@@ -59,12 +55,9 @@ function parse(raw: string): Record<string, unknown> {
function isCanonicalTimestamp(value: unknown): value is string {
if (typeof value !== 'string') return false;
// Nur die eigene Form (toISOString) und nur plausible Jahre (erp-time.ts).
const date = new Date(value);
if (Number.isNaN(date.getTime()) || date.toISOString() !== value) {
return false;
}
const year = date.getUTCFullYear();
return year >= MIN_CURSOR_YEAR && year <= MAX_CURSOR_YEAR;
return isPlausibleErpDate(date) && date.toISOString() === value;
}
/** Prüft den Cursor für genau diesen Endpunkt; sonst 400. */
@@ -16,6 +16,9 @@ type FindManyArgs = {
};
const NOW = new Date('2026-10-12T02:00:00.000Z');
// Die Datenbank-Uhr geht hier bewusst anders als die Uhr des Pods (NOW):
// die Wartezeit muss von der Datenbank kommen.
const DB_SETTLED_UNTIL = new Date('2026-10-12T01:52:30.000Z');
function bottlingRow(id: string, overrides: Record<string, unknown> = {}) {
return {
@@ -69,6 +72,7 @@ describe('ErpExportService', () => {
productionBatch: { findMany: jest.Mock };
batchMovement: { findMany: jest.Mock };
harvest: { findMany: jest.Mock };
$queryRaw: jest.Mock;
};
let stock: {
remainingByBottling: jest.Mock;
@@ -84,6 +88,9 @@ describe('ErpExportService', () => {
productionBatch: { findMany: jest.fn().mockResolvedValue([]) },
batchMovement: { findMany: jest.fn().mockResolvedValue([]) },
harvest: { findMany: jest.fn().mockResolvedValue([]) },
$queryRaw: jest
.fn()
.mockResolvedValue([{ settledUntil: DB_SETTLED_UNTIL }]),
};
stock = {
remainingByBottling: jest.fn().mockResolvedValue(new Map()),
@@ -339,7 +346,48 @@ describe('ErpExportService', () => {
});
describe('movements', () => {
const settledUntil = new Date(NOW.getTime() - MOVEMENT_SETTLE_MS);
const settledUntil = DB_SETTLED_UNTIL;
it('nimmt die Wartezeit von der Datenbank-Uhr, nicht von der Uhr des Pods', async () => {
const page = await service.movements({});
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
const [strings, ...values] = prisma.$queryRaw.mock.calls[0] as [
readonly string[],
...unknown[],
];
expect(strings.join('?')).toContain('now()');
expect(values).toEqual([MOVEMENT_SETTLE_MS]);
expect(values.some((value) => value instanceof Date)).toBe(false);
expect(page.settledUntil).toEqual(DB_SETTLED_UNTIL);
expect(page.settledUntil).not.toEqual(
new Date(NOW.getTime() - MOVEMENT_SETTLE_MS),
);
const where = lastArgs(prisma.batchMovement.findMany).where as {
AND: unknown[];
};
expect(where.AND[1]).toEqual({ createdAt: { lte: DB_SETTLED_UNTIL } });
});
it('liest "seit" mit Zone als UTC-Zeitpunkt', async () => {
await service.movements({ seit: '2026-01-01T01:00:00+01:00' });
const where = lastArgs(prisma.batchMovement.findMany).where as {
AND: unknown[];
};
expect(where.AND[1]).toEqual({
createdAt: {
lte: settledUntil,
gte: new Date('2026-01-01T00:00:00.000Z'),
},
});
});
it('lehnt ein "seit" außerhalb der Vertragsformen ab, ohne die Datenbank zu fragen', async () => {
await expect(
service.movements({ seit: '2026-W01' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.batchMovement.findMany).not.toHaveBeenCalled();
});
it('liefert nur Bewegungen von Abfüllungen und Herstellungschargen, die älter als die Wartezeit sind', async () => {
await service.movements({});
@@ -17,6 +17,7 @@ import {
ErpPageQueryDto,
} from './dto/erp-query.dto';
import { summarizeHarvestYears, type HarvestYear } from './harvest-years';
import { parseSeit } from './erp-time';
/**
* Erhöht nur bei inkompatiblen Änderungen (Feld entfernt, umbenannt oder in
@@ -30,7 +31,9 @@ export const ERP_INVENTORY_CONTRACT_VERSION = 1;
* ihren Zeitstempel beim Einfügen, sichtbar wird sie aber erst beim Commit
* ihrer Transaktion. Ohne Wartezeit könnte ein Abruf eine spätere Buchung
* schon sehen und den Cursor über eine frühere, noch offene hinwegschieben -
* die frühere ginge dem Spiegel dann für immer verloren.
* die frühere ginge dem Spiegel dann für immer verloren. Die Grenze rechnet
* die Datenbank-Uhr (now()), nicht die Uhr des Pods: mehrere api-Pods geben
* so dieselbe Grenze, auch wenn ihre Uhren auseinanderlaufen.
*/
export const MOVEMENT_SETTLE_MS = 5 * 60 * 1000;
@@ -317,7 +320,8 @@ export class ErpExportService {
}
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'bewegungen') : null;
const settledUntil = new Date(Date.now() - MOVEMENT_SETTLE_MS);
const since = query.seit !== undefined ? seitDate(query.seit) : undefined;
const settledUntil = await this.settledUntil();
const afterCursor: Prisma.BatchMovementWhereInput[] = after
? [
@@ -340,7 +344,7 @@ export class ErpExportService {
{
createdAt: {
lte: settledUntil,
...(query.seit !== undefined ? { gte: new Date(query.seit) } : {}),
...(since !== undefined ? { gte: since } : {}),
},
},
...afterCursor,
@@ -386,6 +390,14 @@ export class ErpExportService {
};
}
/** Bis hierhin ist das Bewegungsbuch vollständig: DB-Uhr minus Wartezeit. */
private async settledUntil(): Promise<Date> {
const [row] = await this.prisma.$queryRaw<{ settledUntil: Date }[]>`
SELECT now() - (${MOVEMENT_SETTLE_MS}::double precision * interval '1 millisecond') AS "settledUntil"
`;
return row.settledUntil;
}
private envelope<T>(
page: Omit<ErpPage<T>, 'contractVersion' | 'generatedAt'>,
): ErpPage<T> {
@@ -421,3 +433,14 @@ export class ErpExportService {
});
}
}
/** "seit" ist im DTO schon geprüft; hier nur noch die Umwandlung, sicherheitshalber mit 400. */
function seitDate(seit: string): Date {
const date = parseSeit(seit);
if (!date) {
throw new BadRequestException(
'seit muss JJJJ-MM-TT oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm sein',
);
}
return date;
}
@@ -0,0 +1,51 @@
import { isPlausibleErpDate, parseSeit } from './erp-time';
describe('parseSeit', () => {
it.each([
['2026-01-01', '2026-01-01T00:00:00.000Z'],
['2026-01-01T00:00Z', '2026-01-01T00:00:00.000Z'],
['2026-01-01T08:15:30Z', '2026-01-01T08:15:30.000Z'],
['2026-01-01T08:15:30.1Z', '2026-01-01T08:15:30.100Z'],
['2026-01-01T08:15:30.123Z', '2026-01-01T08:15:30.123Z'],
['2026-01-01T01:00:00+01:00', '2026-01-01T00:00:00.000Z'],
['2025-12-31T21:30:00-02:30', '2026-01-01T00:00:00.000Z'],
])('liest %s als %s (UTC)', (seit, iso) => {
expect(parseSeit(seit)?.toISOString()).toBe(iso);
});
it.each([
'2026-W01',
'20260101',
'-2026-01-01',
'2026-001',
'2026-01-01T00:00',
'2026-01-01T24:00Z',
'2026-02-29',
'2026-04-31',
'1999-12-31T23:59:59Z',
'2101-01-01',
'2100-12-31T23:00:00-02:00',
])('lehnt %s ab', (seit) => {
expect(parseSeit(seit)).toBeNull();
});
it('kennt Schalttage', () => {
expect(parseSeit('2028-02-29')?.toISOString()).toBe(
'2028-02-29T00:00:00.000Z',
);
});
});
describe('isPlausibleErpDate', () => {
it('lässt nur die Jahre 2000 bis 2100 (UTC) zu', () => {
expect(isPlausibleErpDate(new Date('2000-01-01T00:00:00.000Z'))).toBe(true);
expect(isPlausibleErpDate(new Date('2100-12-31T23:59:59.999Z'))).toBe(true);
expect(isPlausibleErpDate(new Date('1999-12-31T23:59:59.999Z'))).toBe(
false,
);
expect(isPlausibleErpDate(new Date('2101-01-01T00:00:00.000Z'))).toBe(
false,
);
expect(isPlausibleErpDate(new Date(Number.NaN))).toBe(false);
});
});
@@ -0,0 +1,69 @@
/**
* Zeitpunkte, die der ERP-Export aus Anfragen übernimmt (`seit`, Cursor).
* Nur plausible Jahre: JavaScript kennt Jahre bis +275760, die
* Datenbankabfrage nicht - das wäre ein 500 statt eines 400.
*/
export const ERP_MIN_YEAR = 2000;
export const ERP_MAX_YEAR = 2100;
export function isPlausibleErpDate(date: Date): boolean {
if (Number.isNaN(date.getTime())) return false;
const year = date.getUTCFullYear();
return year >= ERP_MIN_YEAR && year <= ERP_MAX_YEAR;
}
/**
* Die einzigen Formen für `seit`: ein Kalendertag (`JJJJ-MM-TT`, gilt als
* Mitternacht UTC) oder ein Zeitpunkt mit ausdrücklicher Zone
* (`JJJJ-MM-TTThh:mm[:ss[.sss]]` mit `Z` oder `±hh:mm`). Andere ISO-8601-
* Formen (Wochen, Ordinaltage, Kurzform ohne Striche, erweiterte Jahre,
* Uhrzeit ohne Zone = Ortszeit des Servers) werden abgelehnt, statt falsch
* gelesen zu werden oder als 500 in der Datenbank zu enden.
*/
export const SEIT_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|([+-])(\d{2}):(\d{2})))?$/;
const MS_PER_MINUTE = 60_000;
/** Der Zeitpunkt zu `seit`, oder null, wenn Form, Kalender oder Jahr nicht passen. */
export function parseSeit(value: string): Date | null {
const match = SEIT_PATTERN.exec(value);
if (!match) return null;
// Optionale Gruppen ohne Treffer sind undefined.
const groups: (string | undefined)[] = [...match];
const [, y, mo, d, h, mi, s, frac, , sign, oh, om] = groups;
const year = Number(y);
const month = Number(mo) - 1;
const day = Number(d);
const hours = Number(h ?? 0);
const minutes = Number(mi ?? 0);
const seconds = Number(s ?? 0);
const offsetHours = Number(oh ?? 0);
const offsetMinutes = Number(om ?? 0);
if (
hours > 23 ||
minutes > 59 ||
seconds > 59 ||
offsetHours > 23 ||
offsetMinutes > 59
) {
return null;
}
const calendarDay = new Date(Date.UTC(year, month, day));
if (
calendarDay.getUTCFullYear() !== year ||
calendarDay.getUTCMonth() !== month ||
calendarDay.getUTCDate() !== day
) {
return null; // z. B. 2026-02-30
}
const millis = Number((frac ?? '').padEnd(3, '0'));
const offset =
(sign === '-' ? -1 : 1) *
(offsetHours * 60 + offsetMinutes) *
MS_PER_MINUTE;
const date = new Date(
Date.UTC(year, month, day, hours, minutes, seconds, millis) - offset,
);
return isPlausibleErpDate(date) ? date : null;
}