mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test: add integration tests for html-to-image tool
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
// Mock the browser service -- Chromium may not be available in CI
|
||||
vi.mock("../../apps/api/src/lib/browser-service.js", () => {
|
||||
// 1x1 red PNG as a predictable test fixture
|
||||
const TEST_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
return {
|
||||
isBrowserAvailable: vi.fn().mockReturnValue(true),
|
||||
capturePage: vi.fn().mockResolvedValue(TEST_PNG),
|
||||
shutdownBrowser: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock SSRF validation -- test env has no external network
|
||||
vi.mock("../../apps/api/src/lib/ssrf.js", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
validateFetchUrl: vi.fn().mockImplementation(async (url: string) => {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("Only HTTP and HTTPS URLs are supported");
|
||||
}
|
||||
const hostname = parsed.hostname;
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname.startsWith("192.168.") ||
|
||||
hostname.startsWith("10.") ||
|
||||
hostname.startsWith("172.16.") ||
|
||||
hostname === "[::1]"
|
||||
) {
|
||||
throw new Error("URL resolves to a private or reserved IP address");
|
||||
}
|
||||
return { resolvedIp: "93.184.216.34" };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
function post(payload: Record<string, unknown>, token?: string) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/html-to-image",
|
||||
headers: {
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
describe("HTML to Image", () => {
|
||||
describe("happy path", () => {
|
||||
it("captures a URL with default settings", async () => {
|
||||
const res = await post({ url: "https://example.com" }, adminToken);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.jobId).toBeDefined();
|
||||
expect(result.downloadUrl).toMatch(/^\/api\/v1\/download\//);
|
||||
expect(result.originalSize).toBe(0);
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns a downloadable image at the download URL", async () => {
|
||||
const res = await post({ url: "https://example.com" }, adminToken);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
});
|
||||
expect(dlRes.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts jpg format", async () => {
|
||||
const res = await post({ url: "https://example.com", format: "jpg" }, adminToken);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toMatch(/\.jpg$/);
|
||||
});
|
||||
|
||||
it("accepts webp format", async () => {
|
||||
const res = await post({ url: "https://example.com", format: "webp" }, adminToken);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toMatch(/\.webp$/);
|
||||
});
|
||||
|
||||
it("accepts png format explicitly", async () => {
|
||||
const res = await post({ url: "https://example.com", format: "png" }, adminToken);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toMatch(/\.png$/);
|
||||
});
|
||||
|
||||
it("accepts quality parameter", async () => {
|
||||
const res = await post(
|
||||
{ url: "https://example.com", format: "jpg", quality: 50 },
|
||||
adminToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts fullPage parameter", async () => {
|
||||
const res = await post({ url: "https://example.com", fullPage: true }, adminToken);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts desktop device preset", async () => {
|
||||
const res = await post({ url: "https://example.com", devicePreset: "desktop" }, adminToken);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts tablet device preset", async () => {
|
||||
const res = await post({ url: "https://example.com", devicePreset: "tablet" }, adminToken);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts mobile device preset", async () => {
|
||||
const res = await post({ url: "https://example.com", devicePreset: "mobile" }, adminToken);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts custom viewport dimensions", async () => {
|
||||
const res = await post(
|
||||
{
|
||||
url: "https://example.com",
|
||||
devicePreset: "custom",
|
||||
viewportWidth: 1920,
|
||||
viewportHeight: 1080,
|
||||
},
|
||||
adminToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("passes correct options to capturePage", async () => {
|
||||
const { capturePage } = await import("../../apps/api/src/lib/browser-service.js");
|
||||
(capturePage as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
await post(
|
||||
{
|
||||
url: "https://example.com",
|
||||
format: "jpg",
|
||||
quality: 75,
|
||||
fullPage: true,
|
||||
devicePreset: "mobile",
|
||||
},
|
||||
adminToken,
|
||||
);
|
||||
|
||||
expect(capturePage).toHaveBeenCalledWith("https://example.com", {
|
||||
format: "jpg",
|
||||
quality: 75,
|
||||
fullPage: true,
|
||||
viewportWidth: 375,
|
||||
viewportHeight: 812,
|
||||
isMobile: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation errors (400)", () => {
|
||||
it("rejects missing url field", async () => {
|
||||
const res = await post({}, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects empty url string", async () => {
|
||||
const res = await post({ url: "" }, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects invalid url", async () => {
|
||||
const res = await post({ url: "not-a-url" }, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects invalid format", async () => {
|
||||
const res = await post({ url: "https://example.com", format: "bmp" }, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects quality below 1", async () => {
|
||||
const res = await post({ url: "https://example.com", quality: 0 }, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects quality above 100", async () => {
|
||||
const res = await post({ url: "https://example.com", quality: 101 }, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects viewport width below 320", async () => {
|
||||
const res = await post(
|
||||
{
|
||||
url: "https://example.com",
|
||||
devicePreset: "custom",
|
||||
viewportWidth: 100,
|
||||
},
|
||||
adminToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects viewport width above 3840", async () => {
|
||||
const res = await post(
|
||||
{
|
||||
url: "https://example.com",
|
||||
devicePreset: "custom",
|
||||
viewportWidth: 5000,
|
||||
},
|
||||
adminToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSRF protection (400)", () => {
|
||||
it("blocks localhost", async () => {
|
||||
const res = await post({ url: "http://localhost/" }, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toBe("URL is not allowed");
|
||||
});
|
||||
|
||||
it("blocks 127.0.0.1", async () => {
|
||||
const res = await post({ url: "http://127.0.0.1/" }, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("blocks 192.168.x.x", async () => {
|
||||
const res = await post({ url: "http://192.168.1.1/" }, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("blocks 10.x.x.x", async () => {
|
||||
const res = await post({ url: "http://10.0.0.1/" }, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("blocks 172.16.x.x", async () => {
|
||||
const res = await post({ url: "http://172.16.0.1/" }, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("blocks IPv6 loopback", async () => {
|
||||
const res = await post({ url: "http://[::1]/" }, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("blocks 0.0.0.0", async () => {
|
||||
const res = await post({ url: "http://0.0.0.0/" }, adminToken);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authentication", () => {
|
||||
it("returns 401 without auth token", async () => {
|
||||
const res = await post({ url: "https://example.com" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser unavailable (503)", () => {
|
||||
it("returns 503 when browser is not installed", async () => {
|
||||
const { isBrowserAvailable } = await import("../../apps/api/src/lib/browser-service.js");
|
||||
(isBrowserAvailable as ReturnType<typeof vi.fn>).mockReturnValueOnce(false);
|
||||
|
||||
const res = await post({ url: "https://example.com" }, adminToken);
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(JSON.parse(res.body).code).toBe("BROWSER_NOT_AVAILABLE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("capture errors", () => {
|
||||
it("returns 504 on page load timeout", async () => {
|
||||
const { capturePage } = await import("../../apps/api/src/lib/browser-service.js");
|
||||
(capturePage as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Timeout 30000ms exceeded"),
|
||||
);
|
||||
|
||||
const res = await post({ url: "https://example.com" }, adminToken);
|
||||
expect(res.statusCode).toBe(504);
|
||||
});
|
||||
|
||||
it("returns 422 on generic capture failure", async () => {
|
||||
const { capturePage } = await import("../../apps/api/src/lib/browser-service.js");
|
||||
(capturePage as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("net::ERR_CONNECTION_REFUSED"),
|
||||
);
|
||||
|
||||
const res = await post({ url: "https://example.com" }, adminToken);
|
||||
expect(res.statusCode).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 503 when browser service is crashed", async () => {
|
||||
const { capturePage } = await import("../../apps/api/src/lib/browser-service.js");
|
||||
(capturePage as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Browser service permanently disabled after repeated crashes"),
|
||||
);
|
||||
|
||||
const res = await post({ url: "https://example.com" }, adminToken);
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(JSON.parse(res.body).code).toBe("BROWSER_CRASHED");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user