diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index fd23638..e16927e 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -43,6 +43,35 @@ describe("Health", () => { const data = await res.json(); expect(data.status).toBe("ok"); }); + + it("includes database field", async () => { + const res = await fetch(`${BASE}/health`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.database).toBeDefined(); + expect(data.database.status).toBeDefined(); + }); + + it("includes pool field with size, active, available", async () => { + const res = await fetch(`${BASE}/health`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.pool).toBeDefined(); + expect(data.pool.size).toBeDefined(); + expect(data.pool.active).toBeDefined(); + expect(data.pool.available).toBeDefined(); + expect(typeof data.pool.size).toBe("number"); + expect(typeof data.pool.active).toBe("number"); + expect(typeof data.pool.available).toBe("number"); + }); + + it("includes version field", async () => { + const res = await fetch(`${BASE}/health`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.version).toBeDefined(); + expect(typeof data.version).toBe("string"); + }); }); describe("HTML to PDF", () => { @@ -75,6 +104,93 @@ describe("HTML to PDF", () => { }); expect(res.status).toBe(400); }); + + it("converts HTML with A3 format option", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + html: "

A3 Test

", + options: { format: "A3" }, + }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + const buf = await res.arrayBuffer(); + expect(buf.byteLength).toBeGreaterThan(100); + }); + + it("converts HTML with landscape option", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + html: "

Landscape Test

", + options: { landscape: true }, + }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + }); + + it("converts HTML with margin options", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + html: "

Margin Test

", + options: { margin: { top: "2cm" } }, + }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + }); + + it("rejects invalid JSON body", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: "invalid json{", + }); + expect(res.status).toBe(400); + }); + + it("rejects wrong content-type header", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "text/plain", + }, + body: JSON.stringify({ html: "

Test

" }), + }); + expect(res.status).toBe(415); + }); + + it("handles empty html string", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ html: "" }), + }); + // Empty HTML should still generate a PDF (just blank) + expect(res.status).toBe(200); + }); }); describe("Markdown to PDF", () => { @@ -92,6 +208,156 @@ describe("Markdown to PDF", () => { }); }); +describe("URL to PDF", () => { + it("converts valid URL to PDF", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ url: "https://example.com" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + const buf = await res.arrayBuffer(); + expect(buf.byteLength).toBeGreaterThan(100); + const header = new Uint8Array(buf.slice(0, 5)); + expect(String.fromCharCode(...header)).toBe("%PDF-"); + }); + + it("rejects missing url field", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("url"); + }); + + it("blocks private IP addresses (SSRF protection)", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ url: "http://127.0.0.1" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("private"); + }); + + it("blocks localhost (SSRF protection)", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ url: "http://localhost" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("private"); + }); + + it("rejects invalid protocol (ftp)", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ url: "ftp://example.com" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("http"); + }); + + it("rejects invalid URL format", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ url: "not-a-url" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("Invalid"); + }); +}); + +describe("Demo Endpoints", () => { + it("demo/html converts HTML to PDF without auth", async () => { + const res = await fetch(`${BASE}/v1/demo/html`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ html: "

Demo Test

" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + const buf = await res.arrayBuffer(); + expect(buf.byteLength).toBeGreaterThan(100); + const header = new Uint8Array(buf.slice(0, 5)); + expect(String.fromCharCode(...header)).toBe("%PDF-"); + }); + + it("demo/markdown converts markdown to PDF without auth", async () => { + const res = await fetch(`${BASE}/v1/demo/markdown`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ markdown: "# Demo Markdown\n\nTest content" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + const buf = await res.arrayBuffer(); + expect(buf.byteLength).toBeGreaterThan(100); + }); + + it("demo endpoints are rate-limited", async () => { + // Rate limit is 5 requests per hour + // Make 6 rapid requests - the 6th should fail with 429 + const requests = []; + for (let i = 0; i < 6; i++) { + const promise = fetch(`${BASE}/v1/demo/html`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ html: `

Rate limit test ${i}

` }), + }); + requests.push(promise); + } + + const responses = await Promise.all(requests); + const statuses = responses.map((r) => r.status); + + // At least one should be 429 (rate limited) + expect(statuses).toContain(429); + + // Find the 429 response and check the error message + const rateLimitedResponse = responses.find((r) => r.status === 429); + if (rateLimitedResponse) { + const data = await rateLimitedResponse.json(); + expect(data.error).toContain("limit"); + } + }, 30000); // Increase timeout for this test +}); + describe("Templates", () => { it("lists templates", async () => { const res = await fetch(`${BASE}/v1/templates`, {