305 lines
9.6 KiB
JavaScript
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,
|
|
);
|
|
});
|