mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
558 lines
24 KiB
TypeScript
558 lines
24 KiB
TypeScript
/**
|
|
* Adversarial integration tests for the SnapOtter image API.
|
|
*
|
|
* Tests malicious and stress inputs: corrupted images, truncated data,
|
|
* oversized images, invalid dimensions, injection attacks, and malformed
|
|
* multipart payloads.
|
|
*/
|
|
|
|
import { apiToolPath } from "@snapotter/shared";
|
|
import sharp from "sharp";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { fixtures, readFixture } from "../../fixtures/index.js";
|
|
import {
|
|
buildTestApp,
|
|
createMultipartPayload,
|
|
loginAsAdmin,
|
|
type TestApp,
|
|
} from "../test-server.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
const PNG_200x150 = readFixture(fixtures.image.base.png200);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
|
|
/** Helper to POST a multipart payload to a tool endpoint. */
|
|
function postTool(
|
|
toolId: string,
|
|
fields: Array<{
|
|
name: string;
|
|
filename?: string;
|
|
contentType?: string;
|
|
content: Buffer | string;
|
|
}>,
|
|
) {
|
|
const { body, contentType } = createMultipartPayload(fields);
|
|
return app.inject({
|
|
method: "POST",
|
|
url: apiToolPath(toolId),
|
|
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
|
|
body,
|
|
});
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// CORRUPTED / TRUNCATED IMAGE DATA
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Corrupted and truncated images", () => {
|
|
it("rejects JPEG magic bytes followed by garbage", async () => {
|
|
// Valid JPEG header (FF D8 FF E0) then random garbage
|
|
const corruptedJpeg = Buffer.concat([
|
|
Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
|
|
Buffer.from("this is not a real jpeg image at all, just garbage data"),
|
|
]);
|
|
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "corrupt.jpg", content: corruptedJpeg, contentType: "image/jpeg" },
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
// Should fail gracefully — 400 (validation) or 422 (processing)
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
});
|
|
|
|
it("rejects a truncated PNG (first 100 bytes only)", async () => {
|
|
const truncated = PNG_200x150.subarray(0, 100);
|
|
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "truncated.png", content: truncated, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
]);
|
|
|
|
// A truncated PNG still has valid magic bytes and Sharp can read partial
|
|
// metadata, so validation passes. Processing may succeed (partial decode)
|
|
// or fail gracefully. The key assertion: the server does not crash.
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("rejects random binary garbage as an image", async () => {
|
|
const garbage = Buffer.from(
|
|
Array.from({ length: 2048 }, () => Math.floor(Math.random() * 256)),
|
|
);
|
|
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "garbage.png", content: garbage, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// OVERSIZED IMAGE (MAX_MEGAPIXELS)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Image exceeding MAX_MEGAPIXELS", () => {
|
|
it("rejects an image exceeding the 100MP limit", async () => {
|
|
// Test env has MAX_MEGAPIXELS=100. A 10001x10001 image = ~100.02MP.
|
|
const huge = await sharp({
|
|
create: {
|
|
width: 10001,
|
|
height: 10001,
|
|
channels: 3,
|
|
background: { r: 0, g: 0, b: 0 },
|
|
},
|
|
})
|
|
.png({ compressionLevel: 9 })
|
|
.toBuffer();
|
|
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "huge.png", content: huge, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toMatch(/exceeds maximum size/i);
|
|
}, 60_000);
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// INVALID DIMENSIONS IN SETTINGS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Invalid dimensions in settings", () => {
|
|
it("rejects negative width", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: -1 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toMatch(/invalid settings/i);
|
|
});
|
|
|
|
it("rejects negative height", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ height: -1 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects zero width", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 0 }) },
|
|
]);
|
|
|
|
// z.number().positive() rejects 0
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("handles float dimensions", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 1.5 }) },
|
|
]);
|
|
|
|
// Zod allows floats through z.number().positive() — Sharp will round
|
|
// Should succeed or get rejected by Zod, but not crash
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("handles large width without crashing", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 10000 }) },
|
|
]);
|
|
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// INJECTION ATTACKS IN TEXT FIELDS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Injection attacks in text fields", () => {
|
|
it("SQL injection in text-overlay does not affect database", async () => {
|
|
const res = await postTool("text-overlay", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
text: "'; DROP TABLE users; --",
|
|
fontSize: 24,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
// Text overlay should process normally — SQL injection is in SVG text
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
// Verify the database is still intact by making an auth request
|
|
const healthRes = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/health",
|
|
});
|
|
expect(healthRes.statusCode).toBe(200);
|
|
});
|
|
|
|
it("XSS payload in text-overlay is treated as literal text", async () => {
|
|
const res = await postTool("text-overlay", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
text: "<script>alert(1)</script>",
|
|
fontSize: 24,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
// The escapeXml function should sanitize angle brackets
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// MALFORMED SETTINGS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Malformed settings field", () => {
|
|
it("rejects non-JSON settings string with 400", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: "not json at all" },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toMatch(/json/i);
|
|
});
|
|
|
|
it("rejects settings as JSON array with 400", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: "[]" },
|
|
]);
|
|
|
|
// Zod object schema should reject an array
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects settings as JSON string with 400", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: '"just a string"' },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects settings as JSON number with 400", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: "42" },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// DUPLICATE FILE FIELDS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Duplicate file fields in multipart", () => {
|
|
it("rejects a request with multiple file parts", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "first.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "file", filename: "second.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/resize",
|
|
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
|
|
body,
|
|
});
|
|
|
|
// Tool factory rejects multiple file parts with 400
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toMatch(/too many files/i);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// BINARY GARBAGE IN SETTINGS FIELD
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Binary data in settings field", () => {
|
|
it("rejects random bytes as settings with 400", async () => {
|
|
const randomBytes = Buffer.from(
|
|
Array.from({ length: 64 }, () => Math.floor(Math.random() * 256)),
|
|
);
|
|
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: randomBytes.toString("binary") },
|
|
]);
|
|
|
|
// Should return 400 for invalid JSON
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// ZERO-BYTE AND EMPTY FILE UPLOADS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Zero-byte and empty file uploads (adversarial)", () => {
|
|
it("rejects an empty file with a clear 400 error message", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "empty.jpg", content: Buffer.alloc(0), contentType: "image/jpeg" },
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
expect(json.error.toLowerCase()).toMatch(/no image|empty/i);
|
|
});
|
|
|
|
it("rejects a single null byte as an image", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "one-byte.png",
|
|
content: Buffer.from([0x00]),
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// WRONG MAGIC BYTES (PNG DATA WITH .jpg EXTENSION AND image/jpeg CONTENT TYPE)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Wrong magic bytes — format mismatch", () => {
|
|
it("handles PNG data uploaded as JPEG content type gracefully", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "really-a-png.jpg",
|
|
content: PNG_200x150,
|
|
contentType: "image/jpeg",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
// Sharp detects via magic bytes, not content-type — should succeed
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("rejects a text file renamed to .png", async () => {
|
|
const textAsImage = Buffer.from("This is just plain text, not an image at all.");
|
|
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "fake.png", content: textAsImage, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// TRUNCATED FILES — PARTIAL IMAGE DATA
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Truncated files", () => {
|
|
it("handles first 50 bytes of a valid JPEG gracefully", async () => {
|
|
// Create a minimal JPEG-like header then truncate
|
|
const validJpeg = await sharp({
|
|
create: { width: 10, height: 10, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
|
})
|
|
.jpeg()
|
|
.toBuffer();
|
|
|
|
const truncated = validJpeg.subarray(0, 50);
|
|
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "truncated.jpg", content: truncated, contentType: "image/jpeg" },
|
|
{ name: "settings", content: JSON.stringify({ width: 5 }) },
|
|
]);
|
|
|
|
// Must not crash; either 200 (partial decode), 400, or 422
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("handles first 8 bytes (just the PNG signature) gracefully", async () => {
|
|
// PNG signature is 8 bytes: 89 50 4E 47 0D 0A 1A 0A
|
|
const pngSignatureOnly = PNG_200x150.subarray(0, 8);
|
|
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "sig-only.png",
|
|
content: pngSignatureOnly,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 10 }) },
|
|
]);
|
|
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// BINARY GARBAGE DATA — VARIOUS SIZES
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Binary garbage data at various sizes", () => {
|
|
it("rejects 1 byte of garbage", async () => {
|
|
const res = await postTool("compress", [
|
|
{
|
|
name: "file",
|
|
filename: "tiny-garbage.jpg",
|
|
content: Buffer.from([0xab]),
|
|
contentType: "image/jpeg",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
|
]);
|
|
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("rejects 64KB of random data without crashing", async () => {
|
|
const garbage = Buffer.from(
|
|
Array.from({ length: 65536 }, () => Math.floor(Math.random() * 256)),
|
|
);
|
|
|
|
const res = await postTool("rotate", [
|
|
{ name: "file", filename: "big-garbage.png", content: garbage, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
|
|
]);
|
|
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// EXTREME DIMENSION REQUESTS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Extreme dimension requests", () => {
|
|
it("rejects resize to 0x0 dimensions", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 0, height: 0 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects resize to negative dimensions", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: -100, height: -50 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("handles resize to extremely large dimensions without crashing", async () => {
|
|
const res = await postTool("resize", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 50000, height: 50000 }) },
|
|
]);
|
|
|
|
// May succeed (Sharp allows large), fail at processing (422), or be
|
|
// rejected by validation (400). Must not crash.
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
}, 120_000);
|
|
|
|
it("rejects crop region larger than image dimensions", async () => {
|
|
const res = await postTool("crop", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ left: 0, top: 0, width: 9999, height: 9999 }) },
|
|
]);
|
|
|
|
// Crop extends beyond image — Sharp will fail, should return 422
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("rejects crop with offset beyond image bounds", async () => {
|
|
const res = await postTool("crop", [
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ left: 500, top: 500, width: 10, height: 10 }),
|
|
},
|
|
]);
|
|
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// AUTH — UNAUTHENTICATED ACCESS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("Unauthenticated tool access", () => {
|
|
it("rejects tool requests without auth token", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/resize",
|
|
headers: { "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("rejects tool requests with invalid auth token", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/resize",
|
|
headers: {
|
|
"content-type": contentType,
|
|
authorization: "Bearer totally-fake-token-12345",
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|