Files
Announcements/server/test/image-processing.test.js

305 lines
9.6 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const sharp = require("sharp");
const {
DEFAULT_IMAGE_DERIVATIVE_OPTIONS,
ImageProcessingError,
createImageProcessor,
} = require("../src/image-processing");
const MAX_INPUT_PIXELS = 12_000_000;
const CONTENT_TYPES = Object.freeze({
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
});
function createProcessor(overrides = {}) {
return createImageProcessor({
maxInputPixels: MAX_INPUT_PIXELS,
...overrides,
});
}
function hash(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
async function solidImage(format, width, height, background = "#4a76b8") {
return sharp({
create: {
width,
height,
channels: 4,
background,
},
})
.toFormat(format)
.toBuffer();
}
async function twoFrameImage(format) {
const width = 20;
const frameHeight = 10;
const channels = 4;
const firstFrame = Buffer.alloc(width * frameHeight * channels);
const secondFrame = Buffer.alloc(width * frameHeight * channels);
for (let offset = 0; offset < firstFrame.length; offset += channels) {
firstFrame.set([255, 0, 0, 255], offset);
secondFrame.set([0, 0, 255, 255], offset);
}
const image = sharp(Buffer.concat([firstFrame, secondFrame]), {
raw: {
width,
height: frameHeight * 2,
channels,
pageHeight: frameHeight,
},
});
if (format === "tiff") {
return image.tiff().toBuffer();
}
return image[format]({ delay: [100, 100], loop: 0 }).toBuffer();
}
test("accepts decoded still JPEG, PNG, and WebP content", async (t) => {
const processor = createProcessor();
for (const format of ["jpeg", "png", "webp"]) {
await t.test(format, async () => {
const source = await solidImage(format, 640, 360);
const result = await processor.process(source);
assert.equal(result.original.format, format);
assert.equal(result.original.contentType, CONTENT_TYPES[format]);
assert.equal(result.original.byteSize, source.length);
assert.equal(result.original.sha256, hash(source));
assert.equal(result.original.width, 640);
assert.equal(result.original.height, 360);
assert.equal(result.original.pages, 1);
for (const derivative of [result.display, result.thumbnail]) {
assert.ok(Buffer.isBuffer(derivative.buffer));
assert.equal(derivative.format, "webp");
assert.equal(derivative.contentType, "image/webp");
assert.equal(derivative.byteSize, derivative.buffer.length);
assert.equal(derivative.sha256, hash(derivative.buffer));
assert.match(derivative.sha256, /^[0-9a-f]{64}$/);
}
});
}
});
test("uses bounding boxes without cropping or changing source aspect ratio", async () => {
const source = await solidImage("png", 3000, 1000);
const result = await createProcessor().process(source);
assert.deepEqual(
{ width: result.display.width, height: result.display.height },
{ width: 1920, height: 640 },
);
assert.deepEqual(
{ width: result.thumbnail.width, height: result.thumbnail.height },
{ width: 480, height: 160 },
);
assert.equal(result.display.width / result.display.height, 3);
assert.equal(result.thumbnail.width / result.thumbnail.height, 3);
});
test("does not enlarge a source that fits within both derivative bounds", async () => {
const source = await solidImage("jpeg", 320, 180);
const result = await createProcessor().process(source);
assert.deepEqual(
{ width: result.display.width, height: result.display.height },
{ width: 320, height: 180 },
);
assert.deepEqual(
{ width: result.thumbnail.width, height: result.thumbnail.height },
{ width: 320, height: 180 },
);
});
test("applies EXIF orientation and strips it from derivative metadata", async () => {
const source = await sharp({
create: {
width: 120,
height: 80,
channels: 3,
background: "#cb7235",
},
})
.jpeg()
.withMetadata({ orientation: 6 })
.toBuffer();
const result = await createProcessor().process(source);
const outputMetadata = await sharp(result.display.buffer).metadata();
assert.equal(result.original.orientation, 6);
assert.equal(result.original.encodedWidth, 120);
assert.equal(result.original.encodedHeight, 80);
assert.equal(result.original.width, 80);
assert.equal(result.original.height, 120);
assert.equal(result.display.width, 80);
assert.equal(result.display.height, 120);
assert.equal(outputMetadata.orientation, undefined);
assert.equal(outputMetadata.exif, undefined);
});
test("flattens transparent pixels against black in both derivatives", async () => {
const source = await solidImage("png", 40, 30, {
r: 255,
g: 0,
b: 0,
alpha: 0,
});
const result = await createProcessor().process(source);
assert.equal(result.original.hasAlpha, true);
for (const derivative of [result.display, result.thumbnail]) {
const metadata = await sharp(derivative.buffer).metadata();
const pixels = await sharp(derivative.buffer).raw().toBuffer();
assert.equal(metadata.hasAlpha, false);
assert.ok(
pixels.every((channel) => channel <= 2),
"fully transparent source pixels should become black",
);
}
});
test("supports a temporary file path without changing source identity", async (t) => {
const temporaryDirectory = fs.mkdtempSync(
path.join(os.tmpdir(), "announcements-image-processing-"),
);
t.after(() => fs.rmSync(temporaryDirectory, { recursive: true, force: true }));
const source = await solidImage("webp", 800, 600);
const filename = path.join(temporaryDirectory, "upload.tmp");
fs.writeFileSync(filename, source);
const result = await createProcessor().process(filename);
assert.equal(result.original.sha256, hash(source));
assert.equal(result.original.byteSize, source.length);
assert.equal(result.original.format, "webp");
});
test("rejects malformed and unsupported decoded content with typed errors", async () => {
const processor = createProcessor();
const gif = await solidImage("gif", 64, 64);
await assert.rejects(
processor.process(Buffer.from("not an image")),
(error) =>
error instanceof ImageProcessingError &&
error.code === "MALFORMED_IMAGE" &&
error.statusCode === 422,
);
await assert.rejects(
processor.process(gif),
(error) =>
error instanceof ImageProcessingError &&
error.code === "UNSUPPORTED_IMAGE_FORMAT" &&
error.statusCode === 415 &&
error.details.detectedFormat === "gif",
);
});
test("rejects animated WebP and GIF instead of selecting the first frame", async (t) => {
for (const format of ["webp", "gif"]) {
await t.test(format, async () => {
const animated = await twoFrameImage(format);
const metadata = await sharp(animated, { animated: true }).metadata();
assert.equal(metadata.pages, 2, "test fixture must contain two frames");
await assert.rejects(
createProcessor().process(animated),
(error) =>
error instanceof ImageProcessingError &&
error.code === "ANIMATED_IMAGE_NOT_SUPPORTED" &&
error.details.pages === 2,
);
});
}
});
test("rejects unexpected multipage inputs with a distinct typed error", async () => {
const multipageTiff = await twoFrameImage("tiff");
const metadata = await sharp(multipageTiff, { animated: true }).metadata();
assert.equal(metadata.pages, 2, "test fixture must contain two pages");
await assert.rejects(
createProcessor().process(multipageTiff),
(error) =>
error instanceof ImageProcessingError &&
error.code === "MULTIPAGE_IMAGE_NOT_SUPPORTED" &&
error.details.pages === 2,
);
});
test("enforces the injected decoded-pixel limit", async () => {
const source = await solidImage("png", 100, 100);
const processor = createImageProcessor({ maxInputPixels: 9_999 });
await assert.rejects(
processor.process(source),
(error) =>
error instanceof ImageProcessingError &&
error.code === "IMAGE_PIXEL_LIMIT_EXCEEDED" &&
error.statusCode === 413 &&
error.details.maxInputPixels === 9_999,
);
});
test("uses documented defaults while allowing validated target overrides", async () => {
assert.deepEqual(DEFAULT_IMAGE_DERIVATIVE_OPTIONS, {
display: { maxWidth: 1920, maxHeight: 1080, quality: 82 },
thumbnail: { maxWidth: 480, maxHeight: 270, quality: 72 },
});
const source = await solidImage("png", 900, 600);
const processor = createProcessor({
display: { maxWidth: 300, maxHeight: 300, quality: 90 },
thumbnail: { maxWidth: 100, maxHeight: 50, quality: 65 },
});
const result = await processor.process(source);
assert.deepEqual(
{ width: result.display.width, height: result.display.height },
{ width: 300, height: 200 },
);
assert.deepEqual(
{ width: result.thumbnail.width, height: result.thumbnail.height },
{ width: 75, height: 50 },
);
assert.throws(
() => createImageProcessor({ maxInputPixels: 0 }),
/maxInputPixels must be at least 1/,
);
assert.throws(
() =>
createImageProcessor({
maxInputPixels: 10_000,
display: { quality: 101 },
}),
/display\.quality must be at most 100/,
);
});
test("rejects invalid input interfaces with structured errors", async () => {
await assert.rejects(
createProcessor().process({ path: "/tmp/not-an-interface" }),
(error) =>
error instanceof ImageProcessingError &&
error.code === "INVALID_IMAGE_INPUT" &&
error.statusCode === 400,
);
});