164 lines
7.1 KiB
TypeScript
164 lines
7.1 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { test } from "node:test";
|
||
import { loadRasterImage, prepareRasterSource } from "../src/lib/image-raster";
|
||
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "../src/lib/canvas/canvas-image-data";
|
||
|
||
test("same-origin resource redirects request CORS before assigning src", async () => {
|
||
const originalImage = globalThis.Image;
|
||
const originalLocation = Object.getOwnPropertyDescriptor(globalThis, "location");
|
||
const requests: Array<{ src: string; crossOrigin: string }> = [];
|
||
try {
|
||
Object.defineProperty(globalThis, "location", { configurable: true, value: { href: "https://canvas.example/", origin: "https://canvas.example" } });
|
||
globalThis.Image = class {
|
||
crossOrigin = "";
|
||
naturalWidth = 940;
|
||
naturalHeight = 1674;
|
||
onload?: () => void;
|
||
onerror?: () => void;
|
||
set src(src: string) {
|
||
requests.push({ src, crossOrigin: this.crossOrigin });
|
||
queueMicrotask(() => this.onload?.());
|
||
}
|
||
} as unknown as typeof Image;
|
||
for (const src of ["/api/resources/image/file", "https://cdn.example/image.png", "data:image/png;base64,test", "blob:local-image"]) {
|
||
await loadRasterImage(src);
|
||
}
|
||
assert.deepEqual(
|
||
requests.map((request) => request.crossOrigin),
|
||
["anonymous", "anonymous", "", ""],
|
||
);
|
||
} finally {
|
||
globalThis.Image = originalImage;
|
||
if (originalLocation) Object.defineProperty(globalThis, "location", originalLocation);
|
||
else Reflect.deleteProperty(globalThis, "location");
|
||
}
|
||
});
|
||
|
||
test("crop, resize and split export a redirected resource without taint", async () => {
|
||
const originalImage = globalThis.Image;
|
||
const originalDocument = globalThis.document;
|
||
const originalLocation = Object.getOwnPropertyDescriptor(globalThis, "location");
|
||
const outputs: Array<{ width: number; height: number }> = [];
|
||
try {
|
||
Object.defineProperty(globalThis, "location", { configurable: true, value: { href: "https://canvas.example/", origin: "https://canvas.example" } });
|
||
globalThis.Image = class {
|
||
crossOrigin = "";
|
||
naturalWidth = 940;
|
||
naturalHeight = 1674;
|
||
tainted = false;
|
||
onload?: () => void;
|
||
onerror?: () => void;
|
||
set src(_src: string) {
|
||
// Model a resource endpoint redirecting to a different image origin.
|
||
this.tainted = this.crossOrigin !== "anonymous";
|
||
queueMicrotask(() => this.onload?.());
|
||
}
|
||
} as unknown as typeof Image;
|
||
globalThis.document = {
|
||
createElement() {
|
||
let tainted = false;
|
||
return {
|
||
width: 0,
|
||
height: 0,
|
||
getContext() {
|
||
return {
|
||
drawImage(image: { tainted: boolean }) {
|
||
tainted = image.tainted;
|
||
},
|
||
};
|
||
},
|
||
toDataURL() {
|
||
if (tainted) throw new DOMException("Tainted canvases may not be exported", "SecurityError");
|
||
outputs.push({ width: this.width, height: this.height });
|
||
return "data:image/png;base64,output";
|
||
},
|
||
};
|
||
},
|
||
} as unknown as Document;
|
||
const source = "/api/resources/image/file";
|
||
assert.match(await cropDataUrl(source, { x: 0, y: 0, width: 0.5, height: 0.5 }), /^data:image\/png/);
|
||
assert.match(await upscaleDataUrl(source, { targetLongEdge: 2048, algorithm: "high" }), /^data:image\/png/);
|
||
assert.equal((await splitDataUrl(source, { rows: 2, columns: 2 })).length, 4);
|
||
assert.deepEqual(outputs[0], { width: 470, height: 837 });
|
||
assert.deepEqual(outputs[1], { width: 1150, height: 2048 });
|
||
} finally {
|
||
globalThis.Image = originalImage;
|
||
globalThis.document = originalDocument;
|
||
if (originalLocation) Object.defineProperty(globalThis, "location", originalLocation);
|
||
else Reflect.deleteProperty(globalThis, "location");
|
||
}
|
||
});
|
||
|
||
test("readable local sources skip remote resource reads", async () => {
|
||
for (const url of ["data:image/png;base64,local", "blob:local-image"]) {
|
||
const source = await prepareRasterSource(url, async () => {
|
||
throw new Error("must not read remote bytes");
|
||
});
|
||
assert.equal(source.url, url);
|
||
source.release();
|
||
}
|
||
});
|
||
|
||
test("cached resource bytes use a Blob URL and release it", async () => {
|
||
const blob = new Blob(["pixels"], { type: "image/png" });
|
||
const source = await prepareRasterSource("/api/resources/image/file", async () => blob);
|
||
assert.match(source.url, /^blob:/);
|
||
assert.equal(await (await fetch(source.url)).text(), "pixels");
|
||
source.release();
|
||
await assert.rejects(fetch(source.url));
|
||
});
|
||
|
||
test("resource read failures propagate without falling back to the display URL", async () => {
|
||
const originalFetch = globalThis.fetch;
|
||
let fetched = false;
|
||
const failure = new Error("resource authorization failed");
|
||
try {
|
||
globalThis.fetch = async () => {
|
||
fetched = true;
|
||
throw new Error("unsafe fallback");
|
||
};
|
||
await assert.rejects(
|
||
prepareRasterSource("/api/resources/image/file", async () => {
|
||
throw failure;
|
||
}),
|
||
(error) => error === failure,
|
||
);
|
||
assert.equal(fetched, false);
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test("uncached sources fetch with CORS and create a readable Blob URL", async () => {
|
||
const originalFetch = globalThis.fetch;
|
||
const requests: Array<{ url: unknown; options: unknown }> = [];
|
||
try {
|
||
globalThis.fetch = async (url, options) => {
|
||
requests.push({ url, options });
|
||
return new Response(new Blob(["pixels"], { type: "image/png" }));
|
||
};
|
||
const source = await prepareRasterSource("/api/resources/image/file", async () => null);
|
||
assert.deepEqual(requests, [{ url: "/api/resources/image/file", options: { mode: "cors", credentials: "same-origin" } }]);
|
||
assert.match(source.url, /^blob:/);
|
||
source.release();
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test("HTTP and CORS failures stop processing instead of returning an unsafe URL", async () => {
|
||
const originalFetch = globalThis.fetch;
|
||
try {
|
||
globalThis.fetch = async () => new Response("forbidden", { status: 403 });
|
||
await assert.rejects(prepareRasterSource("https://cdn.example/image.png"), /原图读取失败(403)/);
|
||
const failure = new TypeError("Failed to fetch");
|
||
globalThis.fetch = async () => {
|
||
throw failure;
|
||
};
|
||
await assert.rejects(prepareRasterSource("https://cdn.example/image.png"), (error) => error === failure);
|
||
await assert.rejects(prepareRasterSource(" "), /图片地址为空/);
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
});
|