test: Testabdeckung fuer CSRF-Schutz, Entra-ID-SSO und DHL-Client
Keine der in dieser Session neu hinzugefuegten sicherheitskritischen Module (lib/csrf.ts, lib/entra.ts, lib/dhl.ts) hatte bisher Tests. - csrf.test.ts: Same-Origin-Erkennung, Ablehnung gefaelschter Cross-Origin-Requests, tolerantes Verhalten bei fehlendem/kaputtem Origin-Header. - entra.test.ts: PKCE-Paar-Erzeugung (RFC 7636), Authorize-URL-Aufbau, Extraktion des verifizierten E-Mail-Claims (nie ein unplausibles Feld raten). - dhl.test.ts: Adress-Mapping (Strasse/Hausnummer-Trennung, ISO2->ISO3-Laendercode), Fehlerbehandlung bei nicht unterstuetztem Land, DHL-Validierungsfehlern und HTTP-Fehlern, statt einen falschen Erfolg vorzutaeuschen. Alle 118 Tests (13 Dateien) gruen, tsc --noEmit clean. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
c59913b75b
commit
154b75e0d8
3 files changed
+322
No files matched your search
@@ -0,0 +1,40 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { isTrustedOrigin } from "./csrf";
|
||||
|
||||
function makeRequest(url: string, origin?: string): Request {
|
||||
const headers = new Headers();
|
||||
if (origin !== undefined) headers.set("origin", origin);
|
||||
return new Request(url, { method: "POST", headers });
|
||||
}
|
||||
|
||||
describe("isTrustedOrigin", () => {
|
||||
test("accepts a same-origin request", () => {
|
||||
const request = makeRequest("https://wabenhain.com/api/checkout", "https://wabenhain.com");
|
||||
expect(isTrustedOrigin(request)).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts a same-origin request even with an explicit default port in the URL", () => {
|
||||
const request = makeRequest("https://wabenhain.com:443/api/checkout", "https://wabenhain.com");
|
||||
expect(isTrustedOrigin(request)).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects a cross-origin request (forged CSRF attempt)", () => {
|
||||
const request = makeRequest("https://wabenhain.com/api/checkout", "https://attacker.example");
|
||||
expect(isTrustedOrigin(request)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects a cross-origin request even from a similarly named host", () => {
|
||||
const request = makeRequest("https://wabenhain.com/api/checkout", "https://wabenhain.com.attacker.example");
|
||||
expect(isTrustedOrigin(request)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects a malformed Origin header instead of throwing", () => {
|
||||
const request = makeRequest("https://wabenhain.com/api/checkout", "not-a-valid-url");
|
||||
expect(isTrustedOrigin(request)).toBe(false);
|
||||
});
|
||||
|
||||
test("allows a request with no Origin header (e.g. server-to-server calls)", () => {
|
||||
const request = makeRequest("https://wabenhain.com/api/checkout");
|
||||
expect(isTrustedOrigin(request)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
const ENV_KEYS = ["DHL_API_KEY", "DHL_API_SECRET", "DHL_GKP_USERNAME", "DHL_GKP_PASSWORD", "DHL_ACCOUNT_NUMBER"] as const;
|
||||
const originalEnv: Record<string, string | undefined> = {};
|
||||
|
||||
function configureDhl(): void {
|
||||
process.env.DHL_API_KEY = "api-key";
|
||||
process.env.DHL_API_SECRET = "api-secret";
|
||||
process.env.DHL_GKP_USERNAME = "gkp-user";
|
||||
process.env.DHL_GKP_PASSWORD = "gkp-pass";
|
||||
process.env.DHL_ACCOUNT_NUMBER = "33333333330101";
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const validAddress = {
|
||||
firstName: "Erika",
|
||||
lastName: "Musterfrau",
|
||||
street: "Musterstraße 12a",
|
||||
zip: "12345",
|
||||
city: "Musterstadt",
|
||||
country: "DE",
|
||||
};
|
||||
|
||||
const validShipper = { ...validAddress, companyName: "Wabenhain" };
|
||||
|
||||
// dhl.ts haelt das OAuth2-Token in einem modulweiten In-Memory-Cache (siehe
|
||||
// Kommentar dort) — vi.resetModules() + dynamischer Import pro Test stellt
|
||||
// sicher, dass kein Token zwischen Tests "durchsickert" und einen erwarteten
|
||||
// Token-Request-Mock stillschweigend ueberspringt.
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
for (const key of ENV_KEYS) {
|
||||
originalEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
delete process.env.DHL_ENVIRONMENT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (originalEnv[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = originalEnv[key];
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("isDhlConfigured", () => {
|
||||
test("is false when env vars are missing", async () => {
|
||||
const { isDhlConfigured } = await import("./dhl");
|
||||
expect(isDhlConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
test("is true once all required env vars are set", async () => {
|
||||
configureDhl();
|
||||
const { isDhlConfigured } = await import("./dhl");
|
||||
expect(isDhlConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDhlShipmentLabel", () => {
|
||||
test("throws DhlNotConfiguredError instead of calling the network when unconfigured", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { createDhlShipmentLabel, DhlNotConfiguredError } = await import("./dhl");
|
||||
|
||||
await expect(
|
||||
createDhlShipmentLabel({
|
||||
reference: "WH-2026-0001",
|
||||
weightGrams: 500,
|
||||
shipper: validShipper,
|
||||
receiver: validAddress,
|
||||
})
|
||||
).rejects.toThrow(DhlNotConfiguredError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("splits the street/house-number, maps the ISO2 country to ISO3, and returns the tracking number + label on success", async () => {
|
||||
configureDhl();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
// 1. Token-Aufruf
|
||||
.mockResolvedValueOnce(jsonResponse({ access_token: "token-abc", expires_in: 3600 }))
|
||||
// 2. Sendungserstellung
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
items: [{ shipmentNo: "00340434161094123456", label: { b64: "cGRmLWJ5dGVz" } }],
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { createDhlShipmentLabel } = await import("./dhl");
|
||||
|
||||
const result = await createDhlShipmentLabel({
|
||||
reference: "WH-2026-0001",
|
||||
weightGrams: 500,
|
||||
shipper: validShipper,
|
||||
receiver: validAddress,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ trackingNumber: "00340434161094123456", labelPdfBase64: "cGRmLWJ5dGVz" });
|
||||
|
||||
// Zweiter fetch-Aufruf ist die eigentliche Sendungserstellung — Body
|
||||
// pruefen, dass Adress-Mapping korrekt durchgereicht wurde.
|
||||
const shipmentCall = fetchMock.mock.calls[1];
|
||||
const requestBody = JSON.parse(shipmentCall[1].body as string);
|
||||
const consignee = requestBody.shipments[0].consignee;
|
||||
expect(consignee.address.addressStreet).toBe("Musterstraße");
|
||||
expect(consignee.address.addressHouse).toBe("12a");
|
||||
expect(consignee.address.country).toBe("DEU");
|
||||
expect(shipmentCall[1].headers["dhl-api-key"]).toBe("api-key");
|
||||
expect(shipmentCall[1].headers["Authorization"]).toBe("Bearer token-abc");
|
||||
});
|
||||
|
||||
test("rejects an unsupported destination country instead of guessing a code", async () => {
|
||||
configureDhl();
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ access_token: "token-abc", expires_in: 3600 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { createDhlShipmentLabel, DhlApiError } = await import("./dhl");
|
||||
|
||||
await expect(
|
||||
createDhlShipmentLabel({
|
||||
reference: "WH-2026-0002",
|
||||
weightGrams: 500,
|
||||
shipper: validShipper,
|
||||
receiver: { ...validAddress, country: "US" },
|
||||
})
|
||||
).rejects.toThrow(DhlApiError);
|
||||
});
|
||||
|
||||
test("surfaces a DHL-side validation error instead of returning a fake success", async () => {
|
||||
configureDhl();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ access_token: "token-abc", expires_in: 3600 }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
items: [
|
||||
{
|
||||
validationMessages: [{ validationState: "Error", validationMessage: "postalCode ungültig" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { createDhlShipmentLabel, DhlApiError } = await import("./dhl");
|
||||
|
||||
await expect(
|
||||
createDhlShipmentLabel({
|
||||
reference: "WH-2026-0003",
|
||||
weightGrams: 500,
|
||||
shipper: validShipper,
|
||||
receiver: validAddress,
|
||||
})
|
||||
).rejects.toThrow(DhlApiError);
|
||||
});
|
||||
|
||||
test("surfaces an HTTP error from the DHL API with its detail message", async () => {
|
||||
configureDhl();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ access_token: "token-abc", expires_in: 3600 }))
|
||||
.mockResolvedValueOnce(jsonResponse({ detail: "Ungültige Anfrage" }, 400));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { createDhlShipmentLabel, DhlApiError } = await import("./dhl");
|
||||
|
||||
await expect(
|
||||
createDhlShipmentLabel({
|
||||
reference: "WH-2026-0004",
|
||||
weightGrams: 500,
|
||||
shipper: validShipper,
|
||||
receiver: validAddress,
|
||||
})
|
||||
).rejects.toThrow(/Ungültige Anfrage/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
generatePkcePair,
|
||||
generateOAuthState,
|
||||
buildEntraAuthorizeUrl,
|
||||
getVerifiedEmail,
|
||||
isEntraConfigured,
|
||||
EntraNotConfiguredError,
|
||||
} from "./entra";
|
||||
|
||||
const ENV_KEYS = ["ENTRA_TENANT_ID", "ENTRA_CLIENT_ID", "ENTRA_CLIENT_SECRET", "ENTRA_REDIRECT_URI"] as const;
|
||||
const originalEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
originalEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (originalEnv[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = originalEnv[key];
|
||||
}
|
||||
});
|
||||
|
||||
function configureEntra(): void {
|
||||
process.env.ENTRA_TENANT_ID = "tenant-123";
|
||||
process.env.ENTRA_CLIENT_ID = "client-456";
|
||||
process.env.ENTRA_CLIENT_SECRET = "secret-789";
|
||||
process.env.ENTRA_REDIRECT_URI = "https://wabenhain.com/api/auth/entra/callback";
|
||||
}
|
||||
|
||||
describe("generatePkcePair", () => {
|
||||
test("produces a code_verifier in the RFC 7636 charset and length range", () => {
|
||||
const { codeVerifier } = generatePkcePair();
|
||||
expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/);
|
||||
});
|
||||
|
||||
test("derives code_challenge as base64url(SHA256(code_verifier))", () => {
|
||||
const { codeVerifier, codeChallenge } = generatePkcePair();
|
||||
const expected = createHash("sha256").update(codeVerifier).digest("base64url");
|
||||
expect(codeChallenge).toBe(expected);
|
||||
});
|
||||
|
||||
test("generates a different pair on every call (not reused across requests)", () => {
|
||||
const a = generatePkcePair();
|
||||
const b = generatePkcePair();
|
||||
expect(a.codeVerifier).not.toBe(b.codeVerifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthState", () => {
|
||||
test("generates a non-empty, URL-safe, unpredictable value each call", () => {
|
||||
const a = generateOAuthState();
|
||||
const b = generateOAuthState();
|
||||
expect(a).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(a.length).toBeGreaterThanOrEqual(20);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEntraConfigured / buildEntraAuthorizeUrl", () => {
|
||||
test("reports not configured when env vars are missing", () => {
|
||||
expect(isEntraConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
test("buildEntraAuthorizeUrl throws EntraNotConfiguredError when unconfigured", () => {
|
||||
expect(() => buildEntraAuthorizeUrl({ state: "s", codeChallenge: "c" })).toThrow(EntraNotConfiguredError);
|
||||
});
|
||||
|
||||
test("reports configured and builds a correct authorize URL once env vars are set", () => {
|
||||
configureEntra();
|
||||
expect(isEntraConfigured()).toBe(true);
|
||||
|
||||
const url = new URL(buildEntraAuthorizeUrl({ state: "my-state", codeChallenge: "my-challenge" }));
|
||||
expect(url.origin + url.pathname).toBe("https://login.microsoftonline.com/tenant-123/oauth2/v2.0/authorize");
|
||||
expect(url.searchParams.get("client_id")).toBe("client-456");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe("https://wabenhain.com/api/auth/entra/callback");
|
||||
expect(url.searchParams.get("response_type")).toBe("code");
|
||||
expect(url.searchParams.get("state")).toBe("my-state");
|
||||
expect(url.searchParams.get("code_challenge")).toBe("my-challenge");
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
// "openid" ist zwingend fuer OIDC, "email" liefert den verifizierten
|
||||
// E-Mail-Claim, den getVerifiedEmail() unten voraussetzt.
|
||||
expect(url.searchParams.get("scope")).toContain("openid");
|
||||
expect(url.searchParams.get("scope")).toContain("email");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVerifiedEmail", () => {
|
||||
test("returns the lowercased, trimmed email claim when present and well-formed", () => {
|
||||
expect(getVerifiedEmail({ email: " [email protected] " })).toBe("[email protected]");
|
||||
});
|
||||
|
||||
test("returns null when the email claim is missing", () => {
|
||||
expect(getVerifiedEmail({})).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the email claim doesn't look like an email (never guesses/trusts a bad claim)", () => {
|
||||
expect(getVerifiedEmail({ email: "not-an-email" })).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user