import type { ModuleStorage } from "@ekklesia/module-sdk"; 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, code: string): Promise { 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({ 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 { bodyAccesses += 1; return new ReadableStream({ 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("recognizes a sanitized storage failure from the host's separate SDK copy", async () => { const bucket = storage.bucket("native-images"); const externalError = Object.assign(new Error("The storage service could not be reached."), { code: "STORAGE_PROVIDER_UNAVAILABLE", name: "ModuleStorageError", }); const externalStorage: ModuleStorage = { bucket: () => ({ ...bucket, put: () => Promise.reject(externalError) }), buckets: () => ["native-images"], }; await expectSignageCode( ingestNativeImage({ bytes: await makeImage({ format: "png", height: 360, width: 640 }), displayName: "welcome.png", expiresAt: null, filename: "welcome.png", storage: externalStorage, store, }), "SIGNAGE_STORAGE_UNAVAILABLE", ); }); 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", ); }); });