diff --git a/tests/integration/fetch-urls.test.ts b/tests/integration/fetch-urls.test.ts index 09e26eb4..967ea13b 100644 --- a/tests/integration/fetch-urls.test.ts +++ b/tests/integration/fetch-urls.test.ts @@ -48,6 +48,7 @@ import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; const FIXTURES = join(__dirname, "..", "fixtures"); const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); +const TIFF = readFileSync(join(FIXTURES, "formats", "sample.tiff")); let testApp: TestApp; let app: TestApp["app"]; @@ -84,6 +85,31 @@ function startMockServer(): Promise<{ server: Server; port: number }> { return; } + if (url === "/photo.tiff") { + res.writeHead(200, { "Content-Type": "image/tiff" }); + res.end(TIFF); + return; + } + + if (url === "/empty") { + res.writeHead(200, { "Content-Type": "image/jpeg" }); + res.end(); + return; + } + + if (url === "/server-error") { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Internal Server Error"); + return; + } + + if (url === "/slow-close") { + // Return a valid response with no body stream at all + res.writeHead(200, { "Content-Type": "image/jpeg", "Content-Length": "0" }); + res.end(); + return; + } + res.writeHead(404); res.end("Not Found"); }); @@ -355,4 +381,116 @@ describe("POST /api/v1/fetch-urls", () => { const body = JSON.parse(res.body); expect(body.error).toBeTruthy(); }); + + it("generates a preview for non-browser-previewable formats", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/fetch-urls", + headers: { + authorization: `Bearer ${adminToken}`, + }, + payload: { + urls: [`http://127.0.0.1:${mockPort}/photo.tiff`], + }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.results).toHaveLength(1); + + const result = body.results[0]; + expect(result.success).toBe(true); + expect(result.contentType).toBe("image/tiff"); + expect(result.previewUrl).toBeTruthy(); + expect(result.previewUrl).toContain("preview-"); + expect(result.previewUrl).toContain(".webp"); + + // Preview URL should serve a valid webp image + const previewRes = await app.inject({ + method: "GET", + url: result.previewUrl, + headers: { + authorization: `Bearer ${adminToken}`, + }, + }); + expect(previewRes.statusCode).toBe(200); + }); + + it("returns failure for empty response body", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/fetch-urls", + headers: { + authorization: `Bearer ${adminToken}`, + }, + payload: { + urls: [`http://127.0.0.1:${mockPort}/empty`], + }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.results).toHaveLength(1); + expect(body.results[0].success).toBe(false); + expect(body.results[0].error).toContain("Empty"); + }); + + it("returns failure for 500 server error", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/fetch-urls", + headers: { + authorization: `Bearer ${adminToken}`, + }, + payload: { + urls: [`http://127.0.0.1:${mockPort}/server-error`], + }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.results).toHaveLength(1); + expect(body.results[0].success).toBe(false); + expect(body.results[0].error).toContain("500"); + }); + + it("returns failure when fetch throws a network error", async () => { + // Port 1 is almost guaranteed to refuse connections, triggering the outer + // catch block (lines 275-278 in fetch-urls.ts). + const res = await app.inject({ + method: "POST", + url: "/api/v1/fetch-urls", + headers: { + authorization: `Bearer ${adminToken}`, + }, + payload: { + urls: ["http://127.0.0.1:1/unreachable.jpg"], + }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.results).toHaveLength(1); + expect(body.results[0].success).toBe(false); + expect(body.results[0].error).toBeTruthy(); + }); + + it("returns failure for zero-length content", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/fetch-urls", + headers: { + authorization: `Bearer ${adminToken}`, + }, + payload: { + urls: [`http://127.0.0.1:${mockPort}/slow-close`], + }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.results).toHaveLength(1); + expect(body.results[0].success).toBe(false); + expect(body.results[0].error).toContain("Empty"); + }); }); diff --git a/tests/unit/api/ssrf.test.ts b/tests/unit/api/ssrf.test.ts index 378f7f0d..e1eb4097 100644 --- a/tests/unit/api/ssrf.test.ts +++ b/tests/unit/api/ssrf.test.ts @@ -68,12 +68,85 @@ describe("validateFetchUrl", () => { await expect(validateFetchUrl("http://[2001:DB8::1]/image.jpg")).rejects.toThrow("private"); }); + it("allows a public IP address directly in URL", async () => { + // Exercises the early-return path in resolveAndCheck when hostname is a + // non-private IP literal (covers the `return` after the isIP check). + await expect(validateFetchUrl("http://8.8.8.8/image.jpg")).resolves.toBeUndefined(); + }); + it("rejects invalid URLs", async () => { await expect(validateFetchUrl("not-a-url")).rejects.toThrow(); await expect(validateFetchUrl("")).rejects.toThrow(); }); }); +/** + * Tests that require DNS mocking to exercise resolveAndCheck paths that only + * trigger when the hostname is a non-IP string and lookup returns results. + */ +describe("validateFetchUrl with DNS mocking", () => { + const originalLookup = vi.hoisted(() => { + return { fn: null as null | ((...args: unknown[]) => unknown) }; + }); + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + vi.mock("node:dns/promises", async (importOriginal) => { + const orig = (await importOriginal()) as Record; + originalLookup.fn = orig.lookup as (...args: unknown[]) => unknown; + return { + ...orig, + lookup: vi.fn((...args: unknown[]) => originalLookup.fn?.(...args)), + }; + }); + + it("rejects hostname that resolves to IPv4-mapped IPv6 with private IPv4", async () => { + // Covers isPrivateIPv6 lines 28-31 (::ffff: mapped address path) + const dns = await import("node:dns/promises"); + vi.mocked(dns.lookup).mockResolvedValueOnce([ + { address: "::ffff:127.0.0.1", family: 6 }, + ] as never); + await expect(validateFetchUrl("http://mapped-v6.example.com/image.jpg")).rejects.toThrow( + "private", + ); + }); + + it("rejects hostname resolving to IPv4-mapped IPv6 with 10.x private", async () => { + const dns = await import("node:dns/promises"); + vi.mocked(dns.lookup).mockResolvedValueOnce([ + { address: "::ffff:10.0.0.1", family: 6 }, + ] as never); + await expect(validateFetchUrl("http://mapped-ten.example.com/image.jpg")).rejects.toThrow( + "private", + ); + }); + + it("handles DNS lookup returning a single result object", async () => { + // Covers the Array.isArray fallback branch (line 45: wrapping non-array in []) + const dns = await import("node:dns/promises"); + vi.mocked(dns.lookup).mockResolvedValueOnce({ + address: "203.0.113.1", + family: 4, + } as never); + await expect( + validateFetchUrl("http://single-result.example.com/image.jpg"), + ).resolves.toBeUndefined(); + }); + + it("rejects when DNS returns multiple addresses with one private", async () => { + const dns = await import("node:dns/promises"); + vi.mocked(dns.lookup).mockResolvedValueOnce([ + { address: "203.0.113.1", family: 4 }, + { address: "10.0.0.1", family: 4 }, + ] as never); + await expect(validateFetchUrl("http://dual-addr.example.com/image.jpg")).rejects.toThrow( + "private", + ); + }); +}); + describe("safeFetch", () => { let mockFetch: Mock;