diff --git a/src/__tests__/admin-integration.test.ts b/src/__tests__/admin-integration.test.ts new file mode 100644 index 0000000..ae481da --- /dev/null +++ b/src/__tests__/admin-integration.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +// Mock all heavy dependencies +vi.mock("../services/browser.js", () => ({ + renderPdf: vi.fn(), + renderUrlPdf: vi.fn(), + initBrowser: vi.fn(), + closeBrowser: vi.fn(), +})); + +vi.mock("../services/keys.js", () => ({ + loadKeys: vi.fn(), + getAllKeys: vi.fn().mockReturnValue([]), + isValidKey: vi.fn(), + getKeyInfo: vi.fn(), + isProKey: vi.fn(), + keyStore: new Map(), +})); + +vi.mock("../services/db.js", () => ({ + initDatabase: vi.fn(), + pool: { query: vi.fn(), end: vi.fn() }, + queryWithRetry: vi.fn(), + connectWithRetry: vi.fn(), + cleanupStaleData: vi.fn(), +})); + +vi.mock("../services/verification.js", () => ({ + verifyToken: vi.fn(), + loadVerifications: vi.fn(), +})); + +vi.mock("../middleware/usage.js", () => ({ + usageMiddleware: (_req: any, _res: any, next: any) => next(), + loadUsageData: vi.fn(), + getUsageStats: vi.fn().mockReturnValue({ "test-key": { count: 42, month: "2026-03" } }), + getUsageForKey: vi.fn().mockReturnValue({ count: 10, monthKey: "2026-03" }), + flushDirtyEntries: vi.fn(), +})); + +vi.mock("../middleware/pdfRateLimit.js", () => ({ + pdfRateLimitMiddleware: (_req: any, _res: any, next: any) => next(), + getConcurrencyStats: vi.fn().mockReturnValue({ active: 2, queued: 0, maxConcurrent: 10 }), +})); + +const TEST_KEY = "test-key-123"; +const ADMIN_KEY = "admin-key-456"; + +describe("Admin integration tests", () => { + let app: express.Express; + let originalAdminKey: string | undefined; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + originalAdminKey = process.env.ADMIN_API_KEY; + process.env.ADMIN_API_KEY = ADMIN_KEY; + + // Set up key mocks + const keys = await import("../services/keys.js"); + vi.mocked(keys.isValidKey).mockImplementation((k: string) => k === TEST_KEY || k === ADMIN_KEY); + vi.mocked(keys.getKeyInfo).mockImplementation((k: string) => { + if (k === TEST_KEY) return { key: TEST_KEY, tier: "free" as const, email: "test@test.com", createdAt: "2026-01-01" }; + if (k === ADMIN_KEY) return { key: ADMIN_KEY, tier: "pro" as const, email: "admin@test.com", createdAt: "2026-01-01" }; + return undefined; + }); + vi.mocked(keys.isProKey).mockImplementation((k: string) => k === ADMIN_KEY); + + const { adminRouter } = await import("../routes/admin.js"); + app = express(); + app.use(express.json()); + app.use(adminRouter); + }); + + afterEach(() => { + if (originalAdminKey !== undefined) { + process.env.ADMIN_API_KEY = originalAdminKey; + } else { + delete process.env.ADMIN_API_KEY; + } + }); + + describe("GET /v1/usage/me", () => { + it("returns 401 without auth", async () => { + const res = await request(app).get("/v1/usage/me"); + expect(res.status).toBe(401); + }); + + it("returns 403 with invalid key", async () => { + const res = await request(app).get("/v1/usage/me").set("X-API-Key", "bad-key"); + expect(res.status).toBe(403); + }); + + it("returns usage stats with Bearer auth", async () => { + const res = await request(app).get("/v1/usage/me").set("Authorization", `Bearer ${TEST_KEY}`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + used: 10, + limit: 100, + plan: "demo", + month: "2026-03", + }); + }); + + it("returns usage stats with X-API-Key auth", async () => { + const res = await request(app).get("/v1/usage/me").set("X-API-Key", TEST_KEY); + expect(res.status).toBe(200); + expect(res.body.plan).toBe("demo"); + }); + + it("returns pro plan for pro key", async () => { + const res = await request(app).get("/v1/usage/me").set("X-API-Key", ADMIN_KEY); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + plan: "pro", + limit: 5000, + }); + }); + }); + + describe("GET /v1/usage (admin)", () => { + it("returns 401 without auth", async () => { + const res = await request(app).get("/v1/usage"); + expect(res.status).toBe(401); + }); + + it("returns 403 with non-admin key", async () => { + const res = await request(app).get("/v1/usage").set("X-API-Key", TEST_KEY); + expect(res.status).toBe(403); + expect(res.body.error).toBe("Admin access required"); + }); + + it("returns usage stats with admin key", async () => { + const res = await request(app).get("/v1/usage").set("X-API-Key", ADMIN_KEY); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("test-key"); + }); + + it("returns 503 when ADMIN_API_KEY not set", async () => { + delete process.env.ADMIN_API_KEY; + const res = await request(app).get("/v1/usage").set("X-API-Key", ADMIN_KEY); + expect(res.status).toBe(503); + expect(res.body.error).toBe("Admin access not configured"); + }); + }); + + describe("GET /v1/concurrency (admin)", () => { + it("returns 403 with non-admin key", async () => { + const res = await request(app).get("/v1/concurrency").set("X-API-Key", TEST_KEY); + expect(res.status).toBe(403); + }); + + it("returns concurrency stats with admin key", async () => { + const res = await request(app).get("/v1/concurrency").set("X-API-Key", ADMIN_KEY); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ active: 2, queued: 0, maxConcurrent: 10 }); + }); + }); + + describe("POST /admin/cleanup (admin)", () => { + it("returns 403 with non-admin key", async () => { + const res = await request(app).post("/admin/cleanup").set("X-API-Key", TEST_KEY); + expect(res.status).toBe(403); + }); + + it("returns cleanup results with admin key", async () => { + const { cleanupStaleData } = await import("../services/db.js"); + vi.mocked(cleanupStaleData).mockResolvedValue({ deletedRows: 5 } as any); + + const res = await request(app).post("/admin/cleanup").set("X-API-Key", ADMIN_KEY); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status: "ok", cleaned: { deletedRows: 5 } }); + }); + + it("returns 500 when cleanup fails", async () => { + const { cleanupStaleData } = await import("../services/db.js"); + vi.mocked(cleanupStaleData).mockRejectedValue(new Error("DB error")); + + const res = await request(app).post("/admin/cleanup").set("X-API-Key", ADMIN_KEY); + expect(res.status).toBe(500); + expect(res.body.error).toBe("Cleanup failed"); + }); + }); +}); diff --git a/src/__tests__/pages-integration.test.ts b/src/__tests__/pages-integration.test.ts new file mode 100644 index 0000000..8b875e6 --- /dev/null +++ b/src/__tests__/pages-integration.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeAll } from "vitest"; +import express from "express"; +import request from "supertest"; + +// Mock heavy deps so we don't need DB +vi.mock("../services/browser.js", () => ({ + renderPdf: vi.fn(), + renderUrlPdf: vi.fn(), + initBrowser: vi.fn(), + closeBrowser: vi.fn(), +})); + +vi.mock("../services/keys.js", () => ({ + loadKeys: vi.fn(), + getAllKeys: vi.fn().mockReturnValue([]), + isValidKey: vi.fn().mockReturnValue(false), + getKeyInfo: vi.fn(), + isProKey: vi.fn(), + keyStore: new Map(), +})); + +vi.mock("../services/db.js", () => ({ + initDatabase: vi.fn(), + pool: { query: vi.fn(), end: vi.fn() }, + queryWithRetry: vi.fn(), + connectWithRetry: vi.fn(), + cleanupStaleData: vi.fn(), +})); + +vi.mock("../services/verification.js", () => ({ + verifyToken: vi.fn(), + loadVerifications: vi.fn(), +})); + +vi.mock("../middleware/usage.js", () => ({ + usageMiddleware: (_req: any, _res: any, next: any) => next(), + loadUsageData: vi.fn(), + getUsageStats: vi.fn().mockReturnValue({}), + getUsageForKey: vi.fn().mockReturnValue({ count: 0, monthKey: "2026-03" }), + flushDirtyEntries: vi.fn(), +})); + +vi.mock("../middleware/pdfRateLimit.js", () => ({ + pdfRateLimitMiddleware: (_req: any, _res: any, next: any) => next(), + getConcurrencyStats: vi.fn().mockReturnValue({}), +})); + +describe("Pages integration tests", () => { + let app: express.Express; + + // Use a fresh import per suite to avoid cross-test pollution + beforeAll(async () => { + const { pagesRouter } = await import("../routes/pages.js"); + app = express(); + app.use(pagesRouter); + }); + + describe("GET /favicon.ico", () => { + it("returns SVG with correct Content-Type and Cache-Control", async () => { + const res = await request(app).get("/favicon.ico"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/image\/svg\+xml/); + expect(res.headers["cache-control"]).toContain("public"); + expect(res.headers["cache-control"]).toContain("max-age=604800"); + }); + }); + + describe("GET /openapi.json", () => { + it("returns valid JSON with paths", async () => { + const res = await request(app).get("/openapi.json"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/json/); + expect(res.body.paths).toBeDefined(); + expect(typeof res.body.paths).toBe("object"); + }); + }); + + describe("GET /docs", () => { + it("returns HTML with CSP header", async () => { + const res = await request(app).get("/docs"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/html/); + expect(res.headers["content-security-policy"]).toBeDefined(); + expect(res.headers["content-security-policy"]).toContain("unsafe-eval"); + expect(res.headers["cache-control"]).toContain("max-age=86400"); + }); + }); + + describe("GET /", () => { + it("returns HTML with Cache-Control", async () => { + const res = await request(app).get("/"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/html/); + expect(res.headers["cache-control"]).toContain("public"); + expect(res.headers["cache-control"]).toContain("max-age=3600"); + }); + }); + + describe("Static pages", () => { + const pages = ["/impressum", "/privacy", "/terms", "/examples"]; + + for (const page of pages) { + it(`GET ${page} returns 200 with 24h cache`, async () => { + const res = await request(app).get(page); + expect(res.status).toBe(200); + expect(res.headers["cache-control"]).toContain("public"); + expect(res.headers["cache-control"]).toContain("max-age=86400"); + }); + } + }); + + describe("GET /status", () => { + it("returns 200 with short cache", async () => { + const res = await request(app).get("/status"); + expect(res.status).toBe(200); + expect(res.headers["cache-control"]).toContain("max-age=60"); + }); + }); + + describe("GET /api", () => { + it("returns JSON with version and endpoints", async () => { + const res = await request(app).get("/api"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/json/); + expect(res.body.name).toBe("DocFast API"); + expect(typeof res.body.version).toBe("string"); + expect(Array.isArray(res.body.endpoints)).toBe(true); + expect(res.body.endpoints.length).toBeGreaterThan(0); + }); + }); +});