L10b Betrieb & Aufräumen: OpenAPI 3.1 unter /api/v1/openapi.json, API-Doku und API-Test
- src/lib/api/openapi.ts: statisch gepflegte Spezifikation aller v1-Routen inkl. Fehlerformat, Pagination, Idempotenz (clientOpId/clientId), Konflikte, Rate Limits, Rechte je Operation. - GET /api/v1/openapi.json liefert das Dokument (angemeldete Nutzer). - docs/craftvia/API.md: Kurzdoku mit Endpunkt-Tabelle. - scripts/test-betrieb-api.ts: jede Route nutzt requireApiContext/respond.ts, 401 ohne Sitzung im einheitlichen Format, 403 bei fremdem Origin/Sec-Fetch-Site, Fehler-Mapping, Rate Limit je Nutzer (Standard/Einsatz getrennt), OpenAPI deckt jede route.ts ab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
// Lane L10b „Betrieb & Aufräumen" — /api/v1 vereinheitlicht (Aufräumpunkt a) + Rate Limiting:
|
||||
// Jede Route läuft über requireApiContext + withApi (respond.ts): einheitliches Fehlerformat
|
||||
// { error: { code, message, details? } }, Statuscodes je Code, Same-Origin-Prüfung für jede
|
||||
// Mutation (vor der Authentifizierung), 401 ohne Sitzung, 429 + Retry-After beim Rate Limit.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-betrieb-api.ts (keine DB-Schreibzugriffe)
|
||||
|
||||
import "dotenv/config";
|
||||
// Kleine Limits für den Test — rate-limit.ts liest die Env beim Laden des Moduls.
|
||||
process.env.API_RATE_LIMIT_PER_MINUTE = "5";
|
||||
process.env.API_FIELD_RATE_LIMIT_PER_MINUTE = "12";
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { z } from "zod";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const ROOT = join(process.cwd(), "src/app/api/v1");
|
||||
const HOST = "localhost:3111";
|
||||
const METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"] as const;
|
||||
const MUTATING = new Set(["POST", "PATCH", "PUT", "DELETE"]);
|
||||
|
||||
function routeFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) return routeFiles(p);
|
||||
return name === "route.ts" ? [p] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** `src/app/api/v1/work-orders/[id]/route.ts` → `/api/v1/work-orders/{id}` */
|
||||
function apiPath(file: string): string {
|
||||
const rel = relative(ROOT, file).replace(/\/?route\.ts$/, "");
|
||||
return `/api/v1${rel ? `/${rel}` : ""}`.replace(/\[([^\]]+)\]/g, "{$1}");
|
||||
}
|
||||
|
||||
type Handler = (req: Request, ctx: { params: Promise<Record<string, string>> }) => Promise<Response>;
|
||||
|
||||
async function errorBody(res: Response): Promise<{ code?: string; message?: string } | null> {
|
||||
try {
|
||||
const body = (await res.json()) as { error?: { code?: string; message?: string } };
|
||||
return body.error && typeof body.error === "object" ? body.error : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { ServiceError } = await import("../src/server/services/context");
|
||||
const { ApiError, API_ERROR_STATUS, readJsonObject, toErrorResponse } = await import("../src/server/api/respond");
|
||||
const { enforceApiRateLimit } = await import("../src/server/api/context");
|
||||
const { resetRateLimits } = await import("../src/server/rate-limit");
|
||||
|
||||
const files = routeFiles(ROOT).sort();
|
||||
const apiFiles = files.filter((f) => !f.includes("openapi.json"));
|
||||
ok(apiFiles.length >= 22, `alle v1-Routen gefunden (${apiFiles.length})`);
|
||||
|
||||
console.log("\n— OpenAPI deckt jede Route ab —");
|
||||
const { API_ROUTES, openApiDocument } = await import("../src/lib/api/openapi");
|
||||
const documented = new Set<string>(API_ROUTES);
|
||||
for (const file of files) ok(documented.has(apiPath(file)), `OpenAPI dokumentiert ${apiPath(file)}`);
|
||||
ok(documented.size === files.length, `keine veralteten OpenAPI-Pfade (${documented.size} dokumentiert, ${files.length} Routen)`);
|
||||
const doc = openApiDocument as { openapi?: string; paths?: Record<string, Record<string, unknown>> };
|
||||
ok(typeof doc.openapi === "string" && doc.openapi.startsWith("3.1"), "OpenAPI 3.1");
|
||||
const specRes = await ((await import(pathToFileURL(join(ROOT, "openapi.json/route.ts")).href)) as { GET: () => Promise<Response> }).GET();
|
||||
const spec = (await specRes.json()) as { paths?: Record<string, unknown> };
|
||||
ok(specRes.status === 200 && Object.keys(spec.paths ?? {}).length === files.length, "GET /api/v1/openapi.json liefert das Dokument");
|
||||
|
||||
console.log("\n— Statisch: ein gemeinsamer Adapter —");
|
||||
for (const file of apiFiles) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
const path = apiPath(file);
|
||||
ok(src.includes("requireApiContext(") && !/moduleGuard|action-guard|_context|api-context|reports\/http|_http/.test(src), `${path}: requireApiContext, keine lane-lokalen Kontexte`);
|
||||
ok(/withApi\(|toErrorResponse\(/.test(src), `${path}: Fehler über respond.ts`);
|
||||
}
|
||||
|
||||
console.log("\n— Ohne Sitzung: 401 im einheitlichen Format —");
|
||||
const params = Promise.resolve({ id: "zz-unknown", documentId: "zz-unknown" });
|
||||
for (const file of apiFiles) {
|
||||
const mod = (await import(pathToFileURL(file).href)) as Record<string, unknown>;
|
||||
const path = apiPath(file).replace(/\{[^}]+\}/g, "zz-unknown");
|
||||
for (const method of METHODS) {
|
||||
const handler = mod[method] as Handler | undefined;
|
||||
if (typeof handler !== "function") continue;
|
||||
const headers: Record<string, string> = { host: HOST, accept: "application/json" };
|
||||
const init: RequestInit = { method, headers };
|
||||
if (MUTATING.has(method)) {
|
||||
headers.origin = `http://${HOST}`;
|
||||
headers["sec-fetch-site"] = "same-origin";
|
||||
headers["content-type"] = "application/json";
|
||||
init.body = "{}";
|
||||
}
|
||||
const res = await handler(new Request(`http://${HOST}${path}`, init), { params });
|
||||
const err = await errorBody(res);
|
||||
ok(res.status === 401 && err?.code === "unauthorized" && res.headers.get("cache-control") === "no-store", `${method} ${apiPath(file)} ohne Sitzung → 401 unauthorized`);
|
||||
|
||||
if (MUTATING.has(method)) {
|
||||
const cross = await handler(
|
||||
new Request(`http://${HOST}${path}`, { method, headers: { host: HOST, origin: "https://evil.example", "content-type": "application/json" }, body: "{}" }),
|
||||
{ params },
|
||||
);
|
||||
const crossErr = await errorBody(cross);
|
||||
ok(cross.status === 403 && crossErr?.code === "forbidden", `${method} ${apiPath(file)} fremder Origin → 403 (vor der Anmeldung)`);
|
||||
const site = await handler(
|
||||
new Request(`http://${HOST}${path}`, { method, headers: { host: HOST, "sec-fetch-site": "cross-site", "content-type": "application/json" }, body: "{}" }),
|
||||
{ params },
|
||||
);
|
||||
ok(site.status === 403, `${method} ${apiPath(file)} Sec-Fetch-Site cross-site → 403`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n— Fehler-Mapping (respond.ts) —");
|
||||
const expected: Record<string, number> = { not_found: 404, forbidden: 403, invalid: 422, conflict: 409, blocked: 422 };
|
||||
for (const [code, status] of Object.entries(expected)) {
|
||||
const res = toErrorResponse(new ServiceError(code as "not_found", `msg ${code}`, code === "blocked" ? [{ kind: "checklist_item", id: "c1" }] : undefined));
|
||||
const body = (await res.json()) as { error: { code: string; message: string; details?: unknown } };
|
||||
ok(res.status === status && body.error.code === code && body.error.message === `msg ${code}`, `ServiceError ${code} → ${status}`);
|
||||
if (code === "blocked") ok(Array.isArray(body.error.details), "blocked → details (CompletionBlocker[])");
|
||||
}
|
||||
const zodErr = z.object({ name: z.string() }).safeParse({ name: 1 });
|
||||
const zres = toErrorResponse(zodErr.error);
|
||||
const zbody = (await zres.json()) as { error: { code: string; details: { path: string }[] } };
|
||||
ok(zres.status === 422 && zbody.error.code === "invalid" && zbody.error.details[0]?.path === "name", "ZodError → 422 invalid mit Feldpfaden");
|
||||
const origError = console.error;
|
||||
console.error = () => {};
|
||||
const internal = toErrorResponse(new Error("SELECT secret FROM users"));
|
||||
console.error = origError;
|
||||
const ibody = await internal.text();
|
||||
ok(internal.status === 500 && !ibody.includes("secret"), "unbekannter Fehler → 500 ohne interne Details");
|
||||
ok(API_ERROR_STATUS.rate_limited === 429 && API_ERROR_STATUS.payload_too_large === 413 && API_ERROR_STATUS.unauthorized === 401, "Statuscodes 429/413/401");
|
||||
|
||||
const req = (body: string) => new Request(`http://${HOST}/x`, { method: "POST", body });
|
||||
const code = async (p: Promise<unknown>) => p.then(() => "ok", (e: { code?: string }) => e.code ?? "error");
|
||||
ok(JSON.stringify(await readJsonObject(req(""), { allowEmpty: true })) === "{}", "readJsonObject: leerer Body mit allowEmpty → {}");
|
||||
ok((await code(readJsonObject(req("")))) === "invalid", "readJsonObject: leerer Body → invalid");
|
||||
ok((await code(readJsonObject(req("[1]")))) === "invalid", "readJsonObject: Array → invalid");
|
||||
ok((await code(readJsonObject(req("{nope")))) === "invalid", "readJsonObject: kaputtes JSON → invalid");
|
||||
|
||||
console.log("\n— Rate Limiting je Nutzer —");
|
||||
resetRateLimits();
|
||||
const hit = (user: string, moduleKey: "customers" | "field") => {
|
||||
try {
|
||||
enforceApiRateLimit(user, moduleKey);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return err as InstanceType<typeof ApiError>;
|
||||
}
|
||||
};
|
||||
let firstBlocked = -1;
|
||||
for (let i = 1; i <= 6; i++) if (hit("zz-user-a", "customers") && firstBlocked < 0) firstBlocked = i;
|
||||
ok(firstBlocked === 6, "Standard-Bucket: 5 Anfragen erlaubt, die 6. abgelehnt");
|
||||
const blocked = hit("zz-user-a", "customers");
|
||||
ok(blocked?.code === "rate_limited" && ((blocked.details as { retryAfterSeconds: number }).retryAfterSeconds ?? 0) > 0, "Ablehnung als rate_limited mit retryAfterSeconds");
|
||||
const res429 = toErrorResponse(blocked);
|
||||
ok(res429.status === 429 && Number(res429.headers.get("retry-after")) > 0, "429 mit Retry-After-Header");
|
||||
ok(hit("zz-user-b", "customers") === null, "anderer Nutzer hat eigenes Kontingent");
|
||||
let fieldBlocked = -1;
|
||||
for (let i = 1; i <= 13; i++) if (hit("zz-user-a", "field") && fieldBlocked < 0) fieldBlocked = i;
|
||||
ok(fieldBlocked === 13, "Einsatz-Bucket (sync/uploads/field) getrennt und großzügiger: 12 erlaubt, 13. abgelehnt");
|
||||
resetRateLimits();
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
failures++;
|
||||
})
|
||||
.finally(() => {
|
||||
console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle API-Prüfungen grün");
|
||||
process.exit(failures ? 1 : 0);
|
||||
});
|
||||
Reference in New Issue
Block a user