Files
Signage/tests/images.test.ts
T
2026-08-28 16:41:54 -05:00

503 lines
18 KiB
TypeScript

import sharp from "sharp";
import { beforeEach, describe, expect, it } from "vitest";
import { SignageError } from "../src/server/errors.js";
import { SIGNAGE_IMAGE_MAX_BYTES, SIGNAGE_IMAGE_MAX_PIXELS } from "../src/server/images/limits.js";
import { processSignageImage, sha256Hex } from "../src/server/images/processing.js";
import {
deleteNativeImage,
ingestNativeImage,
updateNativeImage,
} from "../src/server/images/service.js";
import { readBoundedBody } from "../src/server/images/streaming.js";
import { isSixteenByNine, resolveNativeScene } from "../src/shared/native-images.js";
import { createFakeStorage, type FakeStorage } from "./support/fake-storage.js";
import {
bytesOf,
GIF_DOCUMENT_BASE64,
makeAnimatedGif,
makeAnimatedWebp,
makeImage,
PDF_DOCUMENT,
SVG_DOCUMENT,
} from "./support/images.js";
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
async function expectSignageCode(promise: Promise<unknown>, code: string): Promise<SignageError> {
try {
await promise;
} catch (error) {
expect(error).toBeInstanceOf(SignageError);
const signageError = error as SignageError;
expect(signageError.code).toBe(code);
return signageError;
}
throw new Error(`Expected ${code}, but the operation succeeded.`);
}
describe("native image processing", () => {
it("accepts still PNG, JPEG, and WebP and reports the format the bytes actually are", async () => {
for (const format of ["png", "jpeg", "webp"] as const) {
const processed = await processSignageImage(
await makeImage({ format, height: 720, width: 1280 }),
);
expect(processed.format).toBe(format);
expect(processed.width).toBe(1280);
expect(processed.height).toBe(720);
expect(processed.renditions.map((rendition) => rendition.kind)).toEqual([
"display",
"thumbnail",
]);
}
});
it("refuses SVG, PDF, GIF, animation, and anything that is not an image at all", async () => {
await expectSignageCode(
processSignageImage(bytesOf(SVG_DOCUMENT)),
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
);
await expectSignageCode(processSignageImage(bytesOf(PDF_DOCUMENT)), "SIGNAGE_IMAGE_MALFORMED");
await expectSignageCode(
processSignageImage(new Uint8Array(Buffer.from(GIF_DOCUMENT_BASE64, "base64"))),
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
);
await expectSignageCode(
processSignageImage(bytesOf("this is a text file, not a slide")),
"SIGNAGE_IMAGE_MALFORMED",
);
// Animation is detected before format, so an animated GIF reports why it is really refused.
await expectSignageCode(
processSignageImage(await makeAnimatedWebp()),
"SIGNAGE_IMAGE_ANIMATED",
);
await expectSignageCode(processSignageImage(await makeAnimatedGif()), "SIGNAGE_IMAGE_ANIMATED");
});
it("refuses an image whose decoded pixels exceed the budget, whatever the file size is", async () => {
// A decompression bomb is small on disk and enormous decoded, which is exactly why the budget
// is measured in pixels rather than in bytes.
const bomb = await makeImage({ format: "png", height: 9000, width: 9000 });
expect(bomb.byteLength).toBeLessThan(SIGNAGE_IMAGE_MAX_BYTES);
expect(9000 * 9000).toBeGreaterThan(SIGNAGE_IMAGE_MAX_PIXELS);
const error = await expectSignageCode(processSignageImage(bomb), "SIGNAGE_IMAGE_PIXEL_LIMIT");
// The caller learns the limit Signage declared, and nothing about the decoder that enforced it.
expect(error.detail).toEqual({ maxPixels: SIGNAGE_IMAGE_MAX_PIXELS });
expect(error.message).not.toMatch(/sharp|vips|pixel limit exceeded/iu);
});
it("fits renditions inside their bounds, and never crops or upscales", async () => {
const tall = await processSignageImage(
await makeImage({ format: "png", height: 2400, width: 1000 }),
);
const display = tall.renditions.find((rendition) => rendition.kind === "display");
// Inside-fit: the long edge reaches the bound, the short edge is proportional, nothing is cut.
expect(display?.height).toBe(1080);
expect(display?.width).toBe(450);
const small = await processSignageImage(
await makeImage({ format: "png", height: 90, width: 160 }),
);
const smallDisplay = small.renditions.find((rendition) => rendition.kind === "display");
// Never upscaled: blowing a small image up to 1920 makes it soft, not better.
expect(smallDisplay?.width).toBe(160);
expect(smallDisplay?.height).toBe(90);
});
it("strips metadata from renditions and flattens transparency against black", async () => {
const transparent = await processSignageImage(
await makeImage({ alpha: true, format: "png", height: 200, width: 400 }),
);
const display = transparent.renditions.find((rendition) => rendition.kind === "display");
if (!display) {
throw new Error("Expected a display rendition.");
}
const metadata = await sharp(display.bytes).metadata();
expect(metadata.format).toBe("webp");
expect(metadata.hasAlpha).toBe(false);
// EXIF, GPS coordinates, and camera serial numbers never travel with a published rendition.
expect(metadata.exif).toBeUndefined();
expect(metadata.icc).toBeUndefined();
expect(metadata.xmp).toBeUndefined();
// The original's own facts are preserved in PostgreSQL instead.
expect(transparent.hasAlpha).toBe(true);
});
it("is deterministic, so a retry of the same bytes produces the same renditions", async () => {
const bytes = await makeImage({ format: "png", height: 600, width: 800 });
const first = await processSignageImage(bytes);
const second = await processSignageImage(bytes);
expect(first.contentSha256).toBe(second.contentSha256);
expect(first.contentSha256).toBe(sha256Hex(bytes));
expect(first.renditions.map((rendition) => rendition.contentSha256)).toEqual(
second.renditions.map((rendition) => rendition.contentSha256),
);
});
});
describe("native image presentation", () => {
it("lets a true 16:9 image fill the frame, within a documented tolerance", () => {
expect(isSixteenByNine(1920, 1080)).toBe(true);
expect(isSixteenByNine(1280, 720)).toBe(true);
// A one-or-two-pixel export artefact is still 16:9 to a person looking at the wall.
expect(isSixteenByNine(1918, 1080)).toBe(true);
expect(isSixteenByNine(1080, 1080)).toBe(false);
expect(isSixteenByNine(2560, 1080)).toBe(false);
expect(isSixteenByNine(0, 0)).toBe(false);
});
it("shows a 16:9 image whole with no background, blur, or shadow", () => {
const scene = resolveNativeScene(
{ height: 1080, width: 1920 },
{
backgroundColor: "#112233",
backgroundDarkened: true,
backgroundMode: "blurred",
blurPixels: 20,
foregroundShadow: true,
},
);
// An image that already fits needs none of the treatments that exist to frame one that does not.
expect(scene).toEqual({
backgroundColor: "#000000",
backgroundImage: false,
backgroundMode: "none",
blurPixels: 0,
darkenOpacity: 0,
fit: "fill",
shadow: false,
});
});
it("shows every other ratio whole, on the background the slot asked for", () => {
const blurred = resolveNativeScene(
{ height: 1080, width: 1080 },
{
backgroundColor: "#112233",
backgroundDarkened: true,
backgroundMode: "blurred",
blurPixels: 24,
foregroundShadow: true,
},
);
expect(blurred).toMatchObject({
backgroundImage: true,
blurPixels: 24,
darkenOpacity: 0.25,
fit: "contain",
shadow: true,
});
const solid = resolveNativeScene(
{ height: 1080, width: 1080 },
{
backgroundColor: "#112233",
backgroundDarkened: false,
backgroundMode: "solid",
blurPixels: 24,
foregroundShadow: true,
},
);
// A solid background is not blurred, and an undarkened one is not dimmed.
expect(solid).toMatchObject({
backgroundColor: "#112233",
backgroundImage: false,
blurPixels: 0,
darkenOpacity: 0,
fit: "contain",
});
});
});
describe("bounded upload reading", () => {
function streamedRequest(chunks: readonly Uint8Array[], contentLength?: number): Request {
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
return new Request("http://localhost/images?filename=a.png", {
body,
// Required by Node's fetch for a streaming request body.
duplex: "half",
headers: contentLength === undefined ? {} : { "content-length": String(contentLength) },
method: "POST",
});
}
it("refuses a declared length over the limit before the body is even touched", async () => {
let bodyAccesses = 0;
// A stand-in rather than a real `Request`, and `body` is a getter: a `ReadableStream` pulls its
// first chunk as soon as it is constructed, so counting reads would measure the stream rather
// than the check. Counting *accesses to the body* measures exactly the property under test.
const request = {
get body(): ReadableStream<Uint8Array> {
bodyAccesses += 1;
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(16));
controller.close();
},
});
},
headers: new Headers({ "content-length": String(SIGNAGE_IMAGE_MAX_BYTES + 1) }),
} as unknown as Request;
await expectSignageCode(
readBoundedBody(request, SIGNAGE_IMAGE_MAX_BYTES),
"SIGNAGE_IMAGE_TOO_LARGE",
);
expect(bodyAccesses).toBe(0);
});
it("counts what actually arrives, so a missing or lying declared length bounds nothing", async () => {
const chunk = new Uint8Array(1024);
const chunks = Array.from({ length: 8 }, () => chunk);
// No `content-length` at all, and a total over a small limit.
await expectSignageCode(
readBoundedBody(streamedRequest(chunks), 4096),
"SIGNAGE_IMAGE_TOO_LARGE",
);
// A declared length that lies about being small.
await expectSignageCode(
readBoundedBody(streamedRequest(chunks, 10), 4096),
"SIGNAGE_IMAGE_TOO_LARGE",
);
});
it("reassembles a chunked body exactly", async () => {
const image = await makeImage({ format: "png", height: 90, width: 160 });
const half = Math.floor(image.byteLength / 2);
const bytes = await readBoundedBody(
streamedRequest([image.slice(0, half), image.slice(half)]),
SIGNAGE_IMAGE_MAX_BYTES,
);
expect(sha256Hex(bytes)).toBe(sha256Hex(image));
});
it("refuses an empty body rather than treating it as an image", async () => {
await expectSignageCode(
readBoundedBody(streamedRequest([]), SIGNAGE_IMAGE_MAX_BYTES),
"SIGNAGE_INVALID_REQUEST",
);
});
});
describe("native image ingest", () => {
let store: MemorySignageStore;
let storage: FakeStorage;
let counter: number;
beforeEach(() => {
store = createMemorySignageStore();
storage = createFakeStorage();
counter = 0;
});
const newId = () => {
counter += 1;
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
};
async function ingest(bytes: Uint8Array, filename = "welcome.png") {
return ingestNativeImage({
bytes,
displayName: filename,
expiresAt: null,
filename,
newId,
storage,
store,
});
}
it("archives the original unchanged and stores both renditions beside it", async () => {
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
const result = await ingest(bytes);
expect(result.deduplicated).toBe(false);
expect([...storage.objects.keys()].sort()).toEqual([
`native-images/assets/${result.image.id}/display.webp`,
`native-images/assets/${result.image.id}/original.png`,
`native-images/assets/${result.image.id}/thumbnail.webp`,
]);
// Byte for byte. It is the church's file, and Signage is not entitled to rewrite it.
const archived = storage.objects.get(`native-images/assets/${result.image.id}/original.png`);
expect(sha256Hex(archived?.bytes ?? new Uint8Array())).toBe(sha256Hex(bytes));
expect(result.image.originalContentType).toBe("image/png");
expect(result.image.renditions.map((rendition) => rendition.contentType)).toEqual([
"image/webp",
"image/webp",
]);
});
it("returns the existing asset for identical bytes, and writes nothing new", async () => {
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
const first = await ingest(bytes, "welcome.png");
const objectCount = storage.objects.size;
const second = await ingest(bytes, "welcome-copy.png");
expect(second.deduplicated).toBe(true);
expect(second.image.id).toBe(first.image.id);
expect(storage.objects.size).toBe(objectCount);
expect(store.images.size).toBe(1);
});
it("writes nothing at all when the bytes are not an image Signage accepts", async () => {
await expectSignageCode(
ingest(bytesOf(SVG_DOCUMENT), "logo.svg"),
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
);
expect(storage.objects.size).toBe(0);
expect(store.images.size).toBe(0);
});
it("removes every object it wrote when a later write fails", async () => {
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
storage.failPut(`assets/00000000-0000-4000-8000-000000000001/thumbnail.webp`);
await expectSignageCode(ingest(bytes), "SIGNAGE_STORAGE_UNAVAILABLE");
// An aborted ingest leaves no orphan bytes and no half-formed asset.
expect(storage.objects.size).toBe(0);
expect(store.images.size).toBe(0);
});
it("treats a race with an identical upload as a success rather than a failure", async () => {
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
const first = await ingest(bytes);
// The second ingest is forced past the dedup check by clearing the hash lookup once, exactly as
// a concurrent commit would: the write happens, and the unique index refuses the insert.
const originalLookup = store.findReadyImageByContentHash.bind(store);
let lookups = 0;
store.findReadyImageByContentHash = (hash: string) => {
lookups += 1;
return lookups === 1 ? Promise.resolve(undefined) : originalLookup(hash);
};
const second = await ingest(bytes);
expect(second.deduplicated).toBe(true);
expect(second.image.id).toBe(first.image.id);
// The losing attempt cleaned up after itself.
expect(storage.objects.size).toBe(3);
});
it("refuses bytes past Signage's own limit even if the caller read them", async () => {
await expectSignageCode(
ingestNativeImage({
bytes: new Uint8Array(SIGNAGE_IMAGE_MAX_BYTES + 1),
displayName: "huge.png",
expiresAt: null,
filename: "huge.png",
storage,
store,
}),
"SIGNAGE_IMAGE_TOO_LARGE",
);
});
});
describe("native image maintenance", () => {
let store: MemorySignageStore;
let storage: FakeStorage;
async function seed() {
return ingestNativeImage({
bytes: await makeImage({ format: "png", height: 1080, width: 1920 }),
displayName: "welcome.png",
expiresAt: null,
filename: "welcome.png",
storage,
store,
});
}
beforeEach(() => {
store = createMemorySignageStore();
storage = createFakeStorage();
});
it("writes nothing for an exact no-op", async () => {
const { image } = await seed();
let writes = 0;
const original = store.updateImage.bind(store);
store.updateImage = (id, changes) => {
writes += 1;
return original(id, changes);
};
const unchanged = await updateNativeImage({
assetId: image.id,
displayName: image.displayName,
expiresAt: null,
store,
});
expect(unchanged.displayName).toBe(image.displayName);
expect(writes).toBe(0);
});
it("renames and expires an image, and treats a null expiration as permanent", async () => {
const { image } = await seed();
const expires = new Date("2027-01-01T00:00:00.000Z");
const renamed = await updateNativeImage({
assetId: image.id,
displayName: "Welcome slide",
expiresAt: expires,
store,
});
expect(renamed.displayName).toBe("Welcome slide");
expect(renamed.expiresAt?.toISOString()).toBe(expires.toISOString());
const permanent = await updateNativeImage({ assetId: image.id, expiresAt: null, store });
expect(permanent.expiresAt).toBeNull();
});
it("deletes an unreferenced image and every object it owned", async () => {
const { image } = await seed();
await deleteNativeImage({ assetId: image.id, storage, store });
expect(store.images.size).toBe(0);
expect(storage.objects.size).toBe(0);
});
it("refuses to delete an image a slot or a live static hold still refers to", async () => {
const { image } = await seed();
store.addImageReference(image.id, "slot");
store.addImageReference(image.id, "static-hold");
const error = await expectSignageCode(
deleteNativeImage({ assetId: image.id, storage, store }),
"SIGNAGE_ASSET_IN_USE",
);
// Counts an operator can act on — never a slot id, a timeline name, or a channel group's name.
expect(error.detail).toEqual({ heldByChannelGroups: 1, referencedBySlots: 1 });
expect(store.images.size).toBe(1);
expect(storage.objects.size).toBe(3);
});
it("refuses an unknown image without saying whether it ever existed", async () => {
await expectSignageCode(
deleteNativeImage({
assetId: "11111111-1111-4111-8111-111111111111",
storage,
store,
}),
"SIGNAGE_NOT_FOUND",
);
});
});