feat(cert-manager): Zusammenführen mit Fullchain und Nur Kette

- Kettenbau mit Aussteller- und Signaturprüfung (checkIssued plus verify), Wurzel nur auf Wunsch
- build-Route für Fullchain und Nur Kette (PEM), eigene JSON-Grenze 512 KiB mit 413 und Code tooLarge
- Reiter Zusammenführen mit Kettenansicht, Hinweis bei fehlendem Zwischenzertifikat

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:03:23 +02:00
parent 75ea83fc01
commit 30118a2401
24 changed files with 1428 additions and 34 deletions
@@ -0,0 +1,116 @@
import { PassThrough } from 'node:stream';
import { describe, expect, it, vi } from 'vitest';
import {
CERT_BUILD_JSON_LIMIT,
CERT_BUILD_ROUTE,
certBuildBodyErrors,
certBuildJsonBody,
} from './cert-json-body';
import {
CERT_BASENAME_MAX,
CERT_PASSWORD_MAX,
CERT_PEM_MAX,
CERT_POOL_MAX,
} from './dto/cert-build.dto';
/** PEM-aehnlicher Text: eine Zeile je 64 Zeichen, genau `chars` Zeichen lang. */
function pemLike(chars: number): string {
let out = '';
while (out.length < chars) out += `${'A'.repeat(64)}\n`;
return out.slice(0, chars);
}
interface FakeReq extends PassThrough {
headers: Record<string, string>;
method: string;
body?: unknown;
}
function fakeRequest(raw: string): FakeReq {
const req = new PassThrough() as FakeReq;
req.headers = {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(raw)),
};
req.method = 'POST';
req.end(raw);
return req;
}
function runParser(raw: string): Promise<{ req: FakeReq; error: unknown }> {
const req = fakeRequest(raw);
return new Promise((resolve) => {
certBuildJsonBody(req as never, {} as never, (error?: unknown) => resolve({ req, error }));
});
}
function fakeResponse() {
const res = {
statusCode: 0,
payload: undefined as unknown,
status(code: number) {
res.statusCode = code;
return res;
},
json(body: unknown) {
res.payload = body;
return res;
},
};
return res;
}
describe('cert-json-body (D-26)', () => {
it('traegt den Namen certBuildJsonBody, nie jsonParser oder urlencodedParser', () => {
expect(certBuildJsonBody.name).toBe('certBuildJsonBody');
expect(CERT_BUILD_ROUTE).toBe('/modules/cert-manager/build');
expect(CERT_BUILD_JSON_LIMIT).toBe(512 * 1024);
});
it('die groesste Anfrage innerhalb der DTO-Grenzen bleibt unter dem Grenzwert und wird gelesen', async () => {
const body = {
content: 'fullchain',
certPem: pemLike(CERT_PEM_MAX),
poolPems: Array.from({ length: CERT_POOL_MAX }, () => pemLike(CERT_PEM_MAX)),
keyPem: pemLike(CERT_PEM_MAX),
csrPem: pemLike(CERT_PEM_MAX),
password: 'p'.repeat(CERT_PASSWORD_MAX),
baseName: 'b'.repeat(CERT_BASENAME_MAX),
};
const raw = JSON.stringify(body);
expect(Buffer.byteLength(raw)).toBeLessThan(CERT_BUILD_JSON_LIMIT);
const { req, error } = await runParser(raw);
expect(error).toBeUndefined();
expect((req.body as typeof body).poolPems).toHaveLength(CERT_POOL_MAX);
});
it('eine Anfrage ueber 512 KiB wird mit 413 und Code tooLarge beantwortet', async () => {
const { error } = await runParser(JSON.stringify({ content: 'x'.repeat(600 * 1024) }));
const res = fakeResponse();
const next = vi.fn();
certBuildBodyErrors(error, {} as never, res as never, next);
expect(res.statusCode).toBe(413);
expect(res.payload).toMatchObject({ code: 'tooLarge' });
expect(typeof (res.payload as { message: string }).message).toBe('string');
expect(next).not.toHaveBeenCalled();
});
it('fehlerhaftes JSON ergibt 400 mit Code invalidInput', async () => {
const { error } = await runParser('{"content": ');
const res = fakeResponse();
const next = vi.fn();
certBuildBodyErrors(error, {} as never, res as never, next);
expect(res.statusCode).toBe(400);
expect(res.payload).toMatchObject({ code: 'invalidInput' });
expect(next).not.toHaveBeenCalled();
});
it('jeden anderen Fehler reicht die Funktion unveraendert weiter', () => {
const other = new Error('boom');
const res = fakeResponse();
const next = vi.fn();
certBuildBodyErrors(other, {} as never, res as never, next);
expect(next).toHaveBeenCalledWith(other);
expect(res.statusCode).toBe(0);
});
});