mirror of
https://github.com/ashim-hq/ashim
synced 2026-04-21 13:37:52 +00:00
- Delete 3 dead files: use-batch-processor.ts, use-i18n.ts, smart-crop.ts (AI package) - Remove dead getJobProgress function and unused runPythonScript wrapper - Remove 6 unused imports across API and web apps - Remove unused shared types (ImageFormat, AppConfig, ApiError, HealthResponse, JobProgress) and constants (SUPPORTED_INPUT_FORMATS/OUTPUT_FORMATS, DEFAULT_OUTPUT_FORMAT) - Remove unused store method (setOriginalBlobUrl) and clean AI package re-exports - Add test infrastructure: vitest config, unit/integration/e2e tests, fixtures, screenshots - Add Docker test infrastructure: Dockerfile.test, docker-compose.test.yml - Add download_models.py for pre-baking AI model weights in Docker - Add filename sanitization utility (apps/api/src/lib/filename.ts) - Update .gitignore to exclude coverage/, *.tsbuildinfo, .superpowers/, test artifacts - Update .dockerignore to exclude test/coverage/IDE artifacts from builds - Update docs: remove smart crop from AI docs (uses Sharp directly), update bridge docs
2062 lines
71 KiB
TypeScript
2062 lines
71 KiB
TypeScript
/**
|
|
* Integration tests for the Stirling-Image API.
|
|
*
|
|
* These tests build a real Fastify server backed by an in-memory SQLite DB
|
|
* and exercise every endpoint with real HTTP-like requests via `app.inject()`.
|
|
* The goal is adversarial: we try to BREAK the system with invalid inputs,
|
|
* path traversal, missing auth, type confusion, and boundary conditions.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import {
|
|
buildTestApp,
|
|
loginAsAdmin,
|
|
createMultipartPayload,
|
|
type TestApp,
|
|
} from "./test-server.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
const FIXTURES = join(__dirname, "..", "fixtures");
|
|
const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png"));
|
|
const JPG_100x100 = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
|
const WEBP_50x50 = readFileSync(join(FIXTURES, "test-50x50.webp"));
|
|
const PNG_1x1 = readFileSync(join(FIXTURES, "test-1x1.png"));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared state
|
|
// ---------------------------------------------------------------------------
|
|
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);
|
|
|
|
// adminToken is created once in beforeAll — reuse it throughout.
|
|
// Individual tests that need a fresh token should create their own.
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// AUTH ENDPOINTS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Auth endpoints", () => {
|
|
// ── POST /api/auth/login ───────────────────────────────────────
|
|
describe("POST /api/auth/login", () => {
|
|
it("returns a token for valid credentials", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "admin", password: "adminpass" },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.token).toBeDefined();
|
|
expect(typeof body.token).toBe("string");
|
|
expect(body.user.username).toBe("admin");
|
|
expect(body.user.role).toBe("admin");
|
|
expect(body.expiresAt).toBeDefined();
|
|
});
|
|
|
|
it("returns 401 for wrong password", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "admin", password: "wrongpassword" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
expect(JSON.parse(res.body).error).toMatch(/invalid/i);
|
|
});
|
|
|
|
it("returns 401 for non-existent user", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "ghost", password: "whatever123" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("returns 400 when username is missing", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { password: "adminpass" },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 400 when password is missing", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "admin" },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 400 when body is empty", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 400 when body is null", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
headers: { "content-type": "application/json" },
|
|
payload: "null",
|
|
});
|
|
// The route checks body?.username — null body should trigger 400
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("handles SQL injection attempt in username gracefully", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "' OR 1=1 --", password: "anything" },
|
|
});
|
|
// Should NOT succeed — parameterized queries prevent injection
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("handles extremely long username", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "a".repeat(10_000), password: "adminpass" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("handles unicode in credentials", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "\u0000admin", password: "adminpass" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ── GET /api/auth/session ──────────────────────────────────────
|
|
describe("GET /api/auth/session", () => {
|
|
it("returns user info with a valid token", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/auth/session",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.user.username).toBe("admin");
|
|
expect(body.user.role).toBe("admin");
|
|
expect(body.expiresAt).toBeDefined();
|
|
});
|
|
|
|
it("returns 401 with no token", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/auth/session",
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("returns 401 with an invalid token", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/auth/session",
|
|
headers: { authorization: "Bearer totally-fake-token-12345" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("returns 401 with a malformed Authorization header", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/auth/session",
|
|
headers: { authorization: "NotBearer something" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("returns 401 with empty Bearer token", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/auth/session",
|
|
headers: { authorization: "Bearer " },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ── POST /api/auth/logout ──────────────────────────────────────
|
|
describe("POST /api/auth/logout", () => {
|
|
it("invalidates the session token", async () => {
|
|
// Get a fresh token to logout
|
|
const loginRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "admin", password: "adminpass" },
|
|
});
|
|
const freshToken = JSON.parse(loginRes.body).token;
|
|
|
|
// Logout
|
|
const logoutRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/logout",
|
|
headers: { authorization: `Bearer ${freshToken}` },
|
|
});
|
|
expect(logoutRes.statusCode).toBe(200);
|
|
expect(JSON.parse(logoutRes.body).ok).toBe(true);
|
|
|
|
// Verify the token no longer works
|
|
const sessionRes = await app.inject({
|
|
method: "GET",
|
|
url: "/api/auth/session",
|
|
headers: { authorization: `Bearer ${freshToken}` },
|
|
});
|
|
expect(sessionRes.statusCode).toBe(401);
|
|
});
|
|
|
|
it("succeeds even without a token (idempotent)", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/logout",
|
|
});
|
|
// Logout is on a public path prefix (/api/auth/) — should succeed
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ── POST /api/auth/change-password ─────────────────────────────
|
|
describe("POST /api/auth/change-password", () => {
|
|
it("changes password with valid current password", async () => {
|
|
// Create a disposable user first
|
|
const regRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { username: "changepw_user", password: "original1234", role: "user" },
|
|
});
|
|
expect(regRes.statusCode).toBe(201);
|
|
|
|
// Log in as the new user
|
|
const loginRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "changepw_user", password: "original1234" },
|
|
});
|
|
const userToken = JSON.parse(loginRes.body).token;
|
|
|
|
// Change password
|
|
const changeRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/change-password",
|
|
headers: { authorization: `Bearer ${userToken}` },
|
|
payload: { currentPassword: "original1234", newPassword: "newpassword99" },
|
|
});
|
|
expect(changeRes.statusCode).toBe(200);
|
|
expect(JSON.parse(changeRes.body).ok).toBe(true);
|
|
|
|
// Verify old password no longer works
|
|
const oldLoginRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "changepw_user", password: "original1234" },
|
|
});
|
|
expect(oldLoginRes.statusCode).toBe(401);
|
|
|
|
// Verify new password works
|
|
const newLoginRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "changepw_user", password: "newpassword99" },
|
|
});
|
|
expect(newLoginRes.statusCode).toBe(200);
|
|
});
|
|
|
|
it("rejects wrong current password", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/change-password",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { currentPassword: "wrong_password_here", newPassword: "newpass1234" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
expect(JSON.parse(res.body).code).toBe("INVALID_PASSWORD");
|
|
});
|
|
|
|
it("rejects new password shorter than 8 characters", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/change-password",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { currentPassword: "adminpass", newPassword: "short" },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body).code).toBe("VALIDATION_ERROR");
|
|
});
|
|
|
|
it("returns 400 when fields are missing", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/change-password",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { currentPassword: "adminpass" },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 401 without authentication", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/change-password",
|
|
payload: { currentPassword: "adminpass", newPassword: "newpass1234" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ── POST /api/auth/register ────────────────────────────────────
|
|
describe("POST /api/auth/register", () => {
|
|
it("admin can create a new user", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { username: "newuser1", password: "password1234", role: "user" },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.username).toBe("newuser1");
|
|
expect(body.role).toBe("user");
|
|
expect(body.id).toBeDefined();
|
|
});
|
|
|
|
it("returns 409 for duplicate username", async () => {
|
|
// First creation
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { username: "duplicate_user", password: "password1234" },
|
|
});
|
|
// Second attempt
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { username: "duplicate_user", password: "differentpass1" },
|
|
});
|
|
expect(res.statusCode).toBe(409);
|
|
expect(JSON.parse(res.body).code).toBe("CONFLICT");
|
|
});
|
|
|
|
it("rejects password shorter than 8 characters", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { username: "shortpw_user", password: "short" },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 400 when username is missing", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { password: "password1234" },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("non-admin cannot register users", async () => {
|
|
// Create a regular user first
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { username: "regular_user", password: "password1234", role: "user" },
|
|
});
|
|
// Log in as regular user
|
|
const loginRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "regular_user", password: "password1234" },
|
|
});
|
|
const userToken = JSON.parse(loginRes.body).token;
|
|
|
|
// Try to register — should fail with 403
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${userToken}` },
|
|
payload: { username: "sneaky_user", password: "password1234" },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it("defaults role to 'user' when invalid role provided", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { username: "badrole_user", password: "password1234", role: "superadmin" },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
expect(JSON.parse(res.body).role).toBe("user");
|
|
});
|
|
});
|
|
|
|
// ── DELETE /api/auth/users/:id ─────────────────────────────────
|
|
describe("DELETE /api/auth/users/:id", () => {
|
|
it("admin can't delete themselves", async () => {
|
|
// Get the admin's session to find their user ID
|
|
const sessionRes = await app.inject({
|
|
method: "GET",
|
|
url: "/api/auth/session",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const adminId = JSON.parse(sessionRes.body).user.id;
|
|
|
|
const res = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/auth/users/${adminId}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body).code).toBe("SELF_DELETE");
|
|
});
|
|
|
|
it("admin can delete another user", async () => {
|
|
// Create a user to delete
|
|
const regRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { username: "doomed_user", password: "password1234" },
|
|
});
|
|
const userId = JSON.parse(regRes.body).id;
|
|
|
|
const deleteRes = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/auth/users/${userId}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(deleteRes.statusCode).toBe(200);
|
|
expect(JSON.parse(deleteRes.body).ok).toBe(true);
|
|
|
|
// Verify the user can no longer log in
|
|
const loginRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "doomed_user", password: "password1234" },
|
|
});
|
|
expect(loginRes.statusCode).toBe(401);
|
|
});
|
|
|
|
it("returns 404 for non-existent user ID", async () => {
|
|
const res = await app.inject({
|
|
method: "DELETE",
|
|
url: "/api/auth/users/00000000-0000-0000-0000-000000000000",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it("non-admin cannot delete users", async () => {
|
|
// Create + login as regular user
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { username: "nonadmin_deleter", password: "password1234", role: "user" },
|
|
});
|
|
const loginRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "nonadmin_deleter", password: "password1234" },
|
|
});
|
|
const userToken = JSON.parse(loginRes.body).token;
|
|
|
|
const res = await app.inject({
|
|
method: "DELETE",
|
|
url: "/api/auth/users/some-random-id",
|
|
headers: { authorization: `Bearer ${userToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// FILE UPLOAD / DOWNLOAD
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("File upload/download", () => {
|
|
// ── POST /api/v1/upload ────────────────────────────────────────
|
|
describe("POST /api/v1/upload", () => {
|
|
it("uploads a valid PNG and returns a jobId", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "test.png",
|
|
contentType: "image/png",
|
|
content: PNG_200x150,
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/upload",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.jobId).toBeDefined();
|
|
expect(body.files).toHaveLength(1);
|
|
expect(body.files[0].name).toBe("test.png");
|
|
expect(body.files[0].format).toBe("png");
|
|
expect(body.files[0].size).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("uploads multiple files at once", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG_100x100 },
|
|
{ name: "file", filename: "c.webp", contentType: "image/webp", content: WEBP_50x50 },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/upload",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body).files).toHaveLength(3);
|
|
});
|
|
|
|
it("returns 400 for empty multipart (no files)", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
// Only a text field, no file
|
|
{ name: "metadata", content: "some text" },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/upload",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body).error).toMatch(/no valid files/i);
|
|
});
|
|
|
|
it("returns 400 for a non-image file (text file disguised as upload)", async () => {
|
|
const textContent = Buffer.from("This is not an image. Just plain text content.");
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "malicious.txt",
|
|
contentType: "text/plain",
|
|
content: textContent,
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/upload",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects a file with image extension but non-image content", async () => {
|
|
const fakeImage = Buffer.from("#!/bin/bash\necho 'gotcha'");
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "evil.png",
|
|
contentType: "image/png",
|
|
content: fakeImage,
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/upload",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("sanitizes path traversal in filename", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "../../../etc/passwd.png",
|
|
contentType: "image/png",
|
|
content: PNG_1x1,
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/upload",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
// Should succeed but the filename should be sanitized
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.files[0].name).not.toContain("..");
|
|
expect(body.files[0].name).not.toContain("/");
|
|
});
|
|
});
|
|
|
|
// ── GET /api/v1/download/:jobId/:filename ──────────────────────
|
|
describe("GET /api/v1/download/:jobId/:filename", () => {
|
|
let uploadJobId: string;
|
|
let uploadedFilename: string;
|
|
|
|
beforeAll(async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "download-test.png",
|
|
contentType: "image/png",
|
|
content: PNG_200x150,
|
|
},
|
|
]);
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/upload",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
const body = JSON.parse(res.body);
|
|
uploadJobId = body.jobId;
|
|
uploadedFilename = body.files[0].name;
|
|
});
|
|
|
|
it("downloads an uploaded file successfully", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/download/${uploadJobId}/${uploadedFilename}`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-type"]).toContain("image/png");
|
|
expect(res.headers["content-disposition"]).toContain("attachment");
|
|
// Verify the downloaded bytes match what we uploaded
|
|
expect(Buffer.from(res.rawPayload).length).toBe(PNG_200x150.length);
|
|
});
|
|
|
|
it("download is public (no auth required)", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/download/${uploadJobId}/${uploadedFilename}`,
|
|
// No authorization header
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("returns 404 for non-existent file", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/download/${uploadJobId}/nonexistent.png`,
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it("returns 404 for non-existent jobId", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/download/00000000-0000-0000-0000-000000000000/file.png",
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it("returns 400 for path traversal in jobId", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/download/..%2F..%2F..%2Fetc/passwd",
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 400 for path traversal in filename", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/download/${uploadJobId}/..%2F..%2F..%2Fetc%2Fpasswd`,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 400 or 401 for backslash traversal in filename", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/download/${uploadJobId}/..\\..\\..\\etc\\passwd`,
|
|
});
|
|
// Backslash may corrupt the URL path so the public-path check fails (401)
|
|
// or the path-traversal guard catches it (400). Either way, blocked.
|
|
expect([400, 401]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("returns 400 for null bytes in filename", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/download/${uploadJobId}/file.png%00.txt`,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// TOOL PROCESSING
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Tool processing", () => {
|
|
// ── Resize ─────────────────────────────────────────────────────
|
|
describe("POST /api/v1/tools/resize", () => {
|
|
it("resizes an image successfully", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "resize-me.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ width: 100, height: 75 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.jobId).toBeDefined();
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(body.originalSize).toBeGreaterThan(0);
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("resizes by percentage", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "pct.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ percentage: 50 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("returns 400 for invalid settings (negative width)", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "bad.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "settings", content: JSON.stringify({ width: -100 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 400 when no file is provided", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body).error).toMatch(/no image/i);
|
|
});
|
|
|
|
it("returns 400 for malformed settings JSON", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "settings", content: "{not valid json!!!" },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body).error).toMatch(/json/i);
|
|
});
|
|
|
|
it("returns 422 with empty settings (no dimensions given)", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "defaults.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: "{}" },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
// All resize fields are optional at the Zod level, but Sharp needs at
|
|
// least width or height — so processing fails with 422
|
|
expect(res.statusCode).toBe(422);
|
|
});
|
|
|
|
it("download URL from resize result is accessible", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "chain.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
]);
|
|
|
|
const resizeRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
const { downloadUrl } = JSON.parse(resizeRes.body);
|
|
|
|
const downloadRes = await app.inject({
|
|
method: "GET",
|
|
url: downloadUrl,
|
|
});
|
|
expect(downloadRes.statusCode).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ── Crop ───────────────────────────────────────────────────────
|
|
describe("POST /api/v1/tools/crop", () => {
|
|
it("crops an image successfully", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "crop.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ left: 10, top: 10, width: 50, height: 50 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/crop",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
it("returns 400 when required crop fields are missing", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "crop.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ left: 10 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/crop",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 422 when crop exceeds image bounds", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "crop.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ left: 0, top: 0, width: 9999, height: 9999 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/crop",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
// Sharp should throw when crop extends beyond image — wrapped as 422
|
|
expect(res.statusCode).toBe(422);
|
|
});
|
|
});
|
|
|
|
// ── Convert ────────────────────────────────────────────────────
|
|
describe("POST /api/v1/tools/convert", () => {
|
|
it("converts PNG to JPEG", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "convert.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ format: "jpg" }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/convert",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toContain(".jpg");
|
|
});
|
|
|
|
it("converts PNG to WebP", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "convert.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ format: "webp", quality: 80 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/convert",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("rejects unsupported format", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "convert.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "settings", content: JSON.stringify({ format: "bmp" }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/convert",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects quality outside valid range", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "convert.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "settings", content: JSON.stringify({ format: "jpg", quality: 999 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/convert",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ── Rotate ─────────────────────────────────────────────────────
|
|
describe("POST /api/v1/tools/rotate", () => {
|
|
it("rotates 90 degrees", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "rotate.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/rotate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("flips horizontally", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "flip.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ horizontal: true }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/rotate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("works with default settings (no rotation)", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "norotate.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "settings", content: "{}" },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/rotate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ── Nonexistent tool ───────────────────────────────────────────
|
|
describe("POST /api/v1/tools/nonexistent", () => {
|
|
it("returns 404 for a tool that does not exist", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG_1x1 },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/nonexistent",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
});
|
|
|
|
// ── Different input formats ────────────────────────────────────
|
|
describe("Input format handling", () => {
|
|
it("processes JPEG input", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.jpg", contentType: "image/jpeg", content: JPG_100x100 },
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("processes WebP input", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.webp", contentType: "image/webp", content: WEBP_50x50 },
|
|
{ name: "settings", content: JSON.stringify({ width: 25 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// HEALTH & CONFIG
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Health & Config", () => {
|
|
describe("GET /api/v1/health", () => {
|
|
it("returns healthy status without auth", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/health",
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.status).toBe("healthy");
|
|
expect(body.version).toBeDefined();
|
|
expect(body.uptime).toBeDefined();
|
|
});
|
|
|
|
it("also works with auth", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/health",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/v1/config/auth", () => {
|
|
it("returns authEnabled flag without auth", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/config/auth",
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(typeof body.authEnabled).toBe("boolean");
|
|
expect(body.authEnabled).toBe(true); // We set AUTH_ENABLED=true in test-server
|
|
});
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// AUTH MIDDLEWARE
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Auth middleware", () => {
|
|
it("protected route without token returns 401", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/settings",
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("protected route with valid token succeeds", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("public route /api/v1/health works without token", async () => {
|
|
const res = await app.inject({ method: "GET", url: "/api/v1/health" });
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("public route /api/v1/config/auth works without token", async () => {
|
|
const res = await app.inject({ method: "GET", url: "/api/v1/config/auth" });
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("upload route requires auth", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG_1x1 },
|
|
]);
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/upload",
|
|
headers: { "content-type": contentType },
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("tool routes require auth", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "settings", content: "{}" },
|
|
]);
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: { "content-type": contentType },
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("settings routes require auth", async () => {
|
|
const res = await app.inject({
|
|
method: "PUT",
|
|
url: "/api/v1/settings",
|
|
payload: { theme: "dark" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("expired token is rejected", async () => {
|
|
// We can't easily create an expired session through the API, but we can
|
|
// check that a completely fake token is rejected on protected routes.
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: "Bearer expired-fake-token-xyz" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// SETTINGS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Settings", () => {
|
|
describe("GET /api/v1/settings", () => {
|
|
it("returns settings object (requires auth)", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.settings).toBeDefined();
|
|
expect(typeof body.settings).toBe("object");
|
|
});
|
|
|
|
it("returns 401 without auth", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/settings",
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("PUT /api/v1/settings", () => {
|
|
it("admin can save settings", async () => {
|
|
const res = await app.inject({
|
|
method: "PUT",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { theme: "dark", locale: "en" },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.ok).toBe(true);
|
|
expect(body.updatedCount).toBe(2);
|
|
});
|
|
|
|
it("saved settings are retrievable", async () => {
|
|
// Save
|
|
await app.inject({
|
|
method: "PUT",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { testKey: "testValue" },
|
|
});
|
|
|
|
// Retrieve
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/settings/testKey",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.key).toBe("testKey");
|
|
expect(body.value).toBe("testValue");
|
|
});
|
|
|
|
it("non-admin cannot save settings", async () => {
|
|
// Create a regular user
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { username: "settings_user", password: "password1234", role: "user" },
|
|
});
|
|
const loginRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "settings_user", password: "password1234" },
|
|
});
|
|
const userToken = JSON.parse(loginRes.body).token;
|
|
|
|
const res = await app.inject({
|
|
method: "PUT",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: `Bearer ${userToken}` },
|
|
payload: { theme: "hacked" },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it("returns 400 for non-object body", async () => {
|
|
const res = await app.inject({
|
|
method: "PUT",
|
|
url: "/api/v1/settings",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: JSON.stringify([1, 2, 3]),
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("settings upsert works (update existing key)", async () => {
|
|
// Set initial value
|
|
await app.inject({
|
|
method: "PUT",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { upsertKey: "original" },
|
|
});
|
|
|
|
// Update
|
|
await app.inject({
|
|
method: "PUT",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { upsertKey: "updated" },
|
|
});
|
|
|
|
// Verify
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/settings/upsertKey",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(JSON.parse(res.body).value).toBe("updated");
|
|
});
|
|
});
|
|
|
|
describe("GET /api/v1/settings/:key", () => {
|
|
it("returns 404 for non-existent key", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/settings/nonexistent_key_xyz",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// API KEY MANAGEMENT
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("API Keys", () => {
|
|
describe("POST /api/v1/api-keys", () => {
|
|
it("generates a new API key", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/api-keys",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "Test Key" },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.key).toBeDefined();
|
|
expect(body.key).toMatch(/^si_/);
|
|
expect(body.name).toBe("Test Key");
|
|
expect(body.id).toBeDefined();
|
|
});
|
|
|
|
it("uses default name when none provided", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/api-keys",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
expect(JSON.parse(res.body).name).toBe("Default API Key");
|
|
});
|
|
|
|
it("rejects name longer than 100 chars", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/api-keys",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "x".repeat(101) },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("requires auth", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/api-keys",
|
|
payload: { name: "Sneaky Key" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/v1/api-keys", () => {
|
|
it("lists user's API keys (without exposing raw keys)", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/api-keys",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.apiKeys).toBeDefined();
|
|
expect(Array.isArray(body.apiKeys)).toBe(true);
|
|
// Verify that no raw key is returned in list
|
|
for (const key of body.apiKeys) {
|
|
expect(key.key).toBeUndefined();
|
|
expect(key.keyHash).toBeUndefined();
|
|
expect(key.id).toBeDefined();
|
|
expect(key.name).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("DELETE /api/v1/api-keys/:id", () => {
|
|
it("deletes own API key", async () => {
|
|
// Create a key
|
|
const createRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/api-keys",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "To Delete" },
|
|
});
|
|
const keyId = JSON.parse(createRes.body).id;
|
|
|
|
// Delete it
|
|
const deleteRes = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/v1/api-keys/${keyId}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(deleteRes.statusCode).toBe(200);
|
|
expect(JSON.parse(deleteRes.body).ok).toBe(true);
|
|
});
|
|
|
|
it("returns 404 for non-existent key", async () => {
|
|
const res = await app.inject({
|
|
method: "DELETE",
|
|
url: "/api/v1/api-keys/00000000-0000-0000-0000-000000000000",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// PIPELINE
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Pipeline", () => {
|
|
describe("POST /api/v1/pipeline/execute", () => {
|
|
it("executes a multi-step pipeline (resize then rotate)", async () => {
|
|
const pipeline = {
|
|
steps: [
|
|
{ toolId: "resize", settings: { width: 100 } },
|
|
{ toolId: "rotate", settings: { angle: 90 } },
|
|
],
|
|
};
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "pipeline.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "pipeline", content: JSON.stringify(pipeline) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/pipeline/execute",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.stepsCompleted).toBe(2);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
it("returns 400 when pipeline references non-existent tool", async () => {
|
|
const pipeline = {
|
|
steps: [{ toolId: "fake-tool-xyz", settings: {} }],
|
|
};
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "pipeline.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "pipeline", content: JSON.stringify(pipeline) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/pipeline/execute",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 400 when pipeline has no steps", async () => {
|
|
const pipeline = { steps: [] };
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "pipeline.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "pipeline", content: JSON.stringify(pipeline) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/pipeline/execute",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 400 without pipeline definition", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "pipeline.png", contentType: "image/png", content: PNG_1x1 },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/pipeline/execute",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 400 when a step has invalid settings", async () => {
|
|
const pipeline = {
|
|
steps: [
|
|
{ toolId: "crop", settings: { left: -1, top: -1 } }, // invalid: min(0)
|
|
],
|
|
};
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "pipeline.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "pipeline", content: JSON.stringify(pipeline) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/pipeline/execute",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe("Pipeline CRUD", () => {
|
|
let savedPipelineId: string;
|
|
|
|
it("saves a pipeline definition", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/pipeline/save",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: {
|
|
name: "My Pipeline",
|
|
description: "Test pipeline",
|
|
steps: [
|
|
{ toolId: "resize", settings: { width: 100 } },
|
|
{ toolId: "convert", settings: { format: "jpg" } },
|
|
],
|
|
},
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.id).toBeDefined();
|
|
expect(body.name).toBe("My Pipeline");
|
|
savedPipelineId = body.id;
|
|
});
|
|
|
|
it("lists saved pipelines", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/pipeline/list",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.pipelines).toBeDefined();
|
|
expect(body.pipelines.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("deletes a saved pipeline", async () => {
|
|
const res = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/v1/pipeline/${savedPipelineId}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body).ok).toBe(true);
|
|
});
|
|
|
|
it("returns 404 deleting non-existent pipeline", async () => {
|
|
const res = await app.inject({
|
|
method: "DELETE",
|
|
url: "/api/v1/pipeline/00000000-0000-0000-0000-000000000000",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it("rejects pipeline save with invalid tool ID", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/pipeline/save",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: {
|
|
name: "Bad Pipeline",
|
|
steps: [{ toolId: "does-not-exist", settings: {} }],
|
|
},
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects pipeline save with empty name", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/pipeline/save",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: {
|
|
name: "",
|
|
steps: [{ toolId: "resize", settings: {} }],
|
|
},
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// BATCH PROCESSING
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Batch processing", () => {
|
|
describe("POST /api/v1/tools/:toolId/batch", () => {
|
|
it("processes multiple images in a batch and returns ZIP", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "file", filename: "b.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize/batch",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
// Batch streams a ZIP directly — raw status comes from writeHead
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-type"]).toBe("application/zip");
|
|
expect(res.headers["x-job-id"]).toBeDefined();
|
|
});
|
|
|
|
it("returns 404 for non-existent batch tool", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "settings", content: "{}" },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/nonexistent/batch",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it("returns 400 for batch with no files", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize/batch",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// EDGE CASES & ADVERSARIAL INPUTS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Edge cases & adversarial inputs", () => {
|
|
it("rejects a zero-byte file upload", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "empty.png",
|
|
contentType: "image/png",
|
|
content: Buffer.alloc(0),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/upload",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
// Empty files are skipped, resulting in "No valid files uploaded"
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("handles a file with only null bytes", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "nulls.png",
|
|
contentType: "image/png",
|
|
content: Buffer.alloc(1024, 0),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/upload",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("handles concurrent requests without corruption", async () => {
|
|
const requests = Array.from({ length: 5 }, (_, i) => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: `concurrent-${i}.png`, contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "settings", content: JSON.stringify({ width: 1 }) },
|
|
]);
|
|
|
|
return app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
});
|
|
|
|
const results = await Promise.all(requests);
|
|
for (const res of results) {
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.jobId).toBeDefined();
|
|
}
|
|
|
|
// Verify all jobIds are unique
|
|
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
|
|
expect(new Set(jobIds).size).toBe(5);
|
|
});
|
|
|
|
it("rejects a JSON body on multipart-only endpoint", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: { width: 100 },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("handles extremely long tool settings values gracefully", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "settings", content: JSON.stringify({ width: 1, fit: "a".repeat(10_000) }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
// Zod should reject the invalid enum value
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("handles type confusion in settings (string where number expected)", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "settings", content: JSON.stringify({ width: "not-a-number" }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("handles array where object expected in login body", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
headers: { "content-type": "application/json" },
|
|
payload: JSON.stringify(["admin", "adminpass"]),
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("handles deeply nested JSON in settings", async () => {
|
|
// Create a deeply nested object
|
|
let nested: Record<string, unknown> = { value: 1 };
|
|
for (let i = 0; i < 50; i++) {
|
|
nested = { nested };
|
|
}
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "settings", content: JSON.stringify(nested) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
// Zod schema only cares about the expected keys; extra nesting is ignored.
|
|
// Sharp may fail (422) if no valid resize dimensions are derived.
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("handles NaN/Infinity in numeric settings", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG_1x1 },
|
|
{ name: "settings", content: '{"width": NaN}' },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
// NaN is not valid JSON, so it should fail to parse
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects double-encoded path traversal in download", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/download/%252e%252e%252f%252e%252e%252fetc/passwd",
|
|
});
|
|
// Even after double decoding, path traversal should be blocked or 404
|
|
expect([400, 404]).toContain(res.statusCode);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// WORKSPACE INTEGRITY
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Workspace integrity", () => {
|
|
it("upload creates workspace with input dir", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "ws-test.png", contentType: "image/png", content: PNG_1x1 },
|
|
]);
|
|
|
|
const uploadRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/upload",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(uploadRes.statusCode).toBe(200);
|
|
|
|
const { jobId } = JSON.parse(uploadRes.body);
|
|
|
|
// The uploaded file should be downloadable from the workspace
|
|
const downloadRes = await app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/download/${jobId}/ws-test.png`,
|
|
});
|
|
expect(downloadRes.statusCode).toBe(200);
|
|
});
|
|
|
|
it("tool processing creates both input and output in workspace", async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "ws-tool.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
]);
|
|
|
|
const toolRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
expect(toolRes.statusCode).toBe(200);
|
|
|
|
const { jobId, downloadUrl } = JSON.parse(toolRes.body);
|
|
|
|
// Output file is downloadable
|
|
const outputRes = await app.inject({
|
|
method: "GET",
|
|
url: downloadUrl,
|
|
});
|
|
expect(outputRes.statusCode).toBe(200);
|
|
|
|
// Input file is also saved in workspace (from tool-factory.ts)
|
|
const inputRes = await app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/download/${jobId}/ws-tool.png`,
|
|
});
|
|
expect(inputRes.statusCode).toBe(200);
|
|
});
|
|
|
|
it("each job gets a unique workspace (no cross-contamination)", async () => {
|
|
const makeReq = () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "isolated.png", contentType: "image/png", content: PNG_1x1 },
|
|
]);
|
|
return app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/upload",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
payload,
|
|
});
|
|
};
|
|
|
|
const [res1, res2] = await Promise.all([makeReq(), makeReq()]);
|
|
|
|
const jobId1 = JSON.parse(res1.body).jobId;
|
|
const jobId2 = JSON.parse(res2.body).jobId;
|
|
|
|
expect(jobId1).not.toBe(jobId2);
|
|
});
|
|
});
|