mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test: improve coverage for URL import feature
This commit is contained in:
@@ -48,6 +48,7 @@ import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
|
|||||||
|
|
||||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||||
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
||||||
|
const TIFF = readFileSync(join(FIXTURES, "formats", "sample.tiff"));
|
||||||
|
|
||||||
let testApp: TestApp;
|
let testApp: TestApp;
|
||||||
let app: TestApp["app"];
|
let app: TestApp["app"];
|
||||||
@@ -84,6 +85,31 @@ function startMockServer(): Promise<{ server: Server; port: number }> {
|
|||||||
return;
|
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.writeHead(404);
|
||||||
res.end("Not Found");
|
res.end("Not Found");
|
||||||
});
|
});
|
||||||
@@ -355,4 +381,116 @@ describe("POST /api/v1/fetch-urls", () => {
|
|||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
expect(body.error).toBeTruthy();
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,12 +68,85 @@ describe("validateFetchUrl", () => {
|
|||||||
await expect(validateFetchUrl("http://[2001:DB8::1]/image.jpg")).rejects.toThrow("private");
|
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 () => {
|
it("rejects invalid URLs", async () => {
|
||||||
await expect(validateFetchUrl("not-a-url")).rejects.toThrow();
|
await expect(validateFetchUrl("not-a-url")).rejects.toThrow();
|
||||||
await expect(validateFetchUrl("")).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<string, unknown>;
|
||||||
|
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", () => {
|
describe("safeFetch", () => {
|
||||||
let mockFetch: Mock;
|
let mockFetch: Mock;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user