mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
119 lines
5.0 KiB
TypeScript
119 lines
5.0 KiB
TypeScript
import sharp from "sharp";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { env } from "../../../apps/api/src/config.js";
|
|
import { validateImageBuffer } from "../../../apps/api/src/lib/file-validation.js";
|
|
|
|
// Targets the LOGIC survivors of file-validation.ts (L200+): the megapixel
|
|
// limit arithmetic, the null-byte spot-check positions, the SVGZ gzip guard,
|
|
// the HDR text-header guard, and the extension extraction. (The L1-199
|
|
// survivors are static const data tables - MAGIC_BYTES etc. - which a
|
|
// switch-based mutator cannot kill; those are documented, not chased here.)
|
|
|
|
const originalMaxMp = env.MAX_MEGAPIXELS;
|
|
afterEach(() => {
|
|
env.MAX_MEGAPIXELS = originalMaxMp;
|
|
});
|
|
|
|
async function png(width: number, height: number): Promise<Buffer> {
|
|
return sharp({
|
|
create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } },
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
}
|
|
|
|
describe("file-validation megapixel limit (L277/L279/L282)", () => {
|
|
it("rejects an image over the limit with the exact MP and limit in the reason", async () => {
|
|
const buf = await png(2000, 2000); // 4.0 MP
|
|
env.MAX_MEGAPIXELS = 3;
|
|
const res = await validateImageBuffer(buf, "big.png");
|
|
expect(res.valid).toBe(false);
|
|
expect((res as { reason: string }).reason).toBe(
|
|
"Image exceeds maximum size: 4.0MP (limit: 3MP)",
|
|
);
|
|
});
|
|
|
|
it("accepts the same image when the limit is above its size (kills the > comparison)", async () => {
|
|
const buf = await png(2000, 2000); // 4.0 MP
|
|
env.MAX_MEGAPIXELS = 5;
|
|
const res = await validateImageBuffer(buf, "ok.png");
|
|
expect(res).toMatchObject({ valid: true, format: "png", width: 2000, height: 2000 });
|
|
});
|
|
|
|
it("treats MAX_MEGAPIXELS = 0 as unlimited (kills the `> 0 &&` guard)", async () => {
|
|
const buf = await png(2000, 2000);
|
|
env.MAX_MEGAPIXELS = 0;
|
|
const res = await validateImageBuffer(buf, "unlimited.png");
|
|
expect(res).toMatchObject({ valid: true, width: 2000, height: 2000 });
|
|
});
|
|
});
|
|
|
|
describe("file-validation null-byte spot positions (L304-L309)", () => {
|
|
// A 256-byte buffer, all zero except one non-null byte at a spot-check
|
|
// position. isNullByteBuffer must return false (so the verdict is
|
|
// "Unrecognized image format", not "File contains no image data"). Each case
|
|
// pins one of Math.floor(len/4), len/2, 3*len/4, len-1: a mutation of that
|
|
// index checks the wrong slot, misses the lone non-null byte, and wrongly
|
|
// reports the buffer as all-null.
|
|
const LEN = 256;
|
|
const cases: Array<[string, number]> = [
|
|
["len/4", LEN / 4], // 64
|
|
["len/2", LEN / 2], // 128
|
|
["3*len/4", (LEN * 3) / 4], // 192
|
|
["len-1", LEN - 1], // 255
|
|
];
|
|
for (const [label, pos] of cases) {
|
|
it(`a lone non-null byte at ${label} is detected as data, not all-null`, async () => {
|
|
const buf = Buffer.alloc(LEN, 0);
|
|
buf[pos] = 0x7f;
|
|
const res = await validateImageBuffer(buf, "probe.bin");
|
|
expect(res).toEqual({ valid: false, reason: "Unrecognized image format" });
|
|
});
|
|
}
|
|
|
|
it("an all-null large buffer IS reported as containing no image data", async () => {
|
|
const res = await validateImageBuffer(Buffer.alloc(LEN, 0), "empty.bin");
|
|
expect(res).toEqual({ valid: false, reason: "File contains no image data" });
|
|
});
|
|
});
|
|
|
|
describe("file-validation SVGZ gzip guard (L235-L236)", () => {
|
|
it("accepts an .svgz file that starts with the gzip magic 1f 8b", async () => {
|
|
const buf = Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0x00]);
|
|
const res = await validateImageBuffer(buf, "icon.svgz");
|
|
expect(res).toEqual({ valid: true, format: "svg", width: 0, height: 0 });
|
|
});
|
|
|
|
it("rejects an .svgz whose first byte is not 0x1f (kills the byte[0] check)", async () => {
|
|
const buf = Buffer.from([0x1e, 0x8b, 0x08, 0x00, 0x00]);
|
|
const res = await validateImageBuffer(buf, "icon.svgz");
|
|
expect(res.valid).toBe(false);
|
|
});
|
|
|
|
it("rejects an .svgz whose second byte is not 0x8b (kills the byte[1] check)", async () => {
|
|
const buf = Buffer.from([0x1f, 0x8c, 0x08, 0x00, 0x00]);
|
|
const res = await validateImageBuffer(buf, "icon.svgz");
|
|
expect(res.valid).toBe(false);
|
|
});
|
|
|
|
it("rejects an .svgz shorter than 2 bytes (kills the length >= 2 guard)", async () => {
|
|
const res = await validateImageBuffer(Buffer.from([0x1f]), "tiny.svgz");
|
|
expect(res.valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("file-validation extension extraction + TGA (L216, L228)", () => {
|
|
it("uses the lowercased last dot-segment: an uppercase .TGA name resolves to tga", async () => {
|
|
// 18 non-null bytes with no known magic; only the extension makes it TGA.
|
|
const buf = Buffer.alloc(18, 0x11);
|
|
const res = await validateImageBuffer(buf, "scan.final.TGA");
|
|
expect(res).toMatchObject({ valid: true, format: "tga" });
|
|
});
|
|
|
|
it("without a filename there is no extension, so the same bytes are unrecognized", async () => {
|
|
const buf = Buffer.alloc(18, 0x11);
|
|
const res = await validateImageBuffer(buf);
|
|
expect(res).toEqual({ valid: false, reason: "Unrecognized image format" });
|
|
});
|
|
});
|