528 lines
19 KiB
TypeScript
528 lines
19 KiB
TypeScript
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<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("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",
|
|
);
|
|
});
|
|
});
|