diff --git a/src/lib/csrf.test.ts b/src/lib/csrf.test.ts new file mode 100644 index 0000000..3012527 --- /dev/null +++ b/src/lib/csrf.test.ts @@ -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); + }); +}); diff --git a/src/lib/dhl.test.ts b/src/lib/dhl.test.ts new file mode 100644 index 0000000..593875b --- /dev/null +++ b/src/lib/dhl.test.ts @@ -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 = {}; + +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/); + }); +}); diff --git a/src/lib/entra.test.ts b/src/lib/entra.test.ts new file mode 100644 index 0000000..64400f8 --- /dev/null +++ b/src/lib/entra.test.ts @@ -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 = {}; + +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: " Nick@Example.COM " })).toBe("nick@example.com"); + }); + + 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(); + }); +});