ai-chatbot/tests/routes/image-delivery-qiniu.test.ts

258 lines
14 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { NextRequest } from "next/server";
import sharp from "sharp";
import { GET } from "@/app/api/image-proxy/route";
import { buildImageProxyUrl, getFullscreenPreviewSize, getImageDownloadExtension } from "@/lib/image-delivery";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import { NextResponse } from "next/server";
test.describe("Qiniu image delivery", () => {
test("model result buckets use our thumbnail endpoint without forbidden OSS transforms", () => {
const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/output.png";
const preview = new URL(buildImageProxyUrl(source, { width: 960, format: "webp" }), "https://app.example");
expect(preview.pathname).toBe("/api/image-proxy");
expect(preview.searchParams.get("url")).toBe(source);
expect(preview.searchParams.has("x-oss-process")).toBe(false);
expect(buildImageProxyUrl("https://other-bucket.oss-cn-shanghai.aliyuncs.com/image.png", { width: 960 })).toContain("x-oss-process=");
});
test("a completed archive serves old model-result references from Qiniu immediately", async () => {
const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/archived.png";
const route = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({ [source]: "https://yybb.top/archived.png" }) },
fetch: () => { throw new Error("Must use the completed archive"); },
})("app/api/image-proxy/route.ts");
const request = new URL("https://app.example/api/image-proxy");
request.searchParams.set("url", source); request.searchParams.set("w", "960"); request.searchParams.set("f", "webp");
const response = await route.GET(new NextRequest(request, { headers: { "sec-fetch-dest": "image" } }));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("https://yybb.top/archived.png?imageView2/2/w/960/format/webp/q/82");
});
test("unarchived model previews share one original fetch with archiving and return resized WebP", async () => {
const source = `https://dashscope-a717.oss-accelerate.aliyuncs.com/${crypto.randomUUID()}.png`;
const original = await sharp({ create: { width: 1024, height: 1024, channels: 3, background: "#345678" } }).png().toBuffer();
let requests = 0;
const load = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({}) },
fetch: async (url: string) => {
expect(url).toBe(source); requests++;
await new Promise(resolve => setTimeout(resolve, 30));
return new Response(original, { headers: { "content-type": "image/png" } });
},
});
const route = load("app/api/image-proxy/route.ts");
const archiveRead = load("lib/storage/image-byte-cache.ts").fetchImageBytes(source);
const responses = await Promise.all([100, 200].map(width => {
const request = new URL("https://app.example/api/image-proxy");
request.searchParams.set("url", source); request.searchParams.set("w", String(width)); request.searchParams.set("f", "webp");
return route.GET(new NextRequest(request, { headers: { "sec-fetch-dest": "image" } }));
}));
expect((await archiveRead).buffer.equals(original)).toBe(true);
expect(requests).toBe(1);
for (let i = 0; i < responses.length; i++) {
expect(responses[i].status).toBe(200);
expect(responses[i].headers.get("content-type")).toBe("image/webp");
expect((await sharp(Buffer.from(await responses[i].arrayBuffer())).metadata()).width).toBe([100, 200][i]);
}
});
test("sizes a fullscreen square to its visible height and reuses the chat preview on standard density screens", () => {
const size = getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884 });
expect(size.displayWidth).toBeCloseTo(839.8);
expect(size.requestWidth).toBe(960);
expect(buildImageProxyUrl("https://yybb.top/a.png", { width: size.requestWidth, quality: 86, format: "webp" }))
.toBe(buildImageProxyUrl("https://yybb.top/a.png", { width: 960, quality: 86, format: "webp" }));
});
test("preserves screen density and image aspect ratio without requesting oversized fixed previews", () => {
expect(getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884, pixelRatio: 2 }).requestWidth).toBe(2048);
const portrait = getFullscreenPreviewSize({ imageWidth: 1000, imageHeight: 2000, viewportWidth: 1440, viewportHeight: 900, pixelRatio: 2 });
expect(portrait.displayWidth).toBeCloseTo(427.5);
expect(portrait.requestWidth).toBe(960);
expect(getFullscreenPreviewSize({ imageWidth: 4000, imageHeight: 1000, viewportWidth: 5000, viewportHeight: 2000, pixelRatio: 2 }).requestWidth).toBe(4096);
});
test("reports a timeout while reading image bytes as a retryable response", async () => {
const previousFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) { controller.error(new DOMException("source stalled", "TimeoutError")); },
}), { headers: { "Content-Type": "image/png" } });
try {
const response = await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fslow.png&proxy=1&w=192"));
expect(response.status).toBe(504);
expect(await response.json()).toMatchObject({ error: "读取源图片超时,请重试" });
} finally { globalThis.fetch = previousFetch; }
});
test("stops the upstream image body when the preview request is cancelled", async () => {
const previousFetch = globalThis.fetch;
const controller = new AbortController();
let upstreamSignal: AbortSignal | undefined;
globalThis.fetch = async (_url, init) => {
upstreamSignal = init?.signal as AbortSignal;
return new Response(new ReadableStream({ start(stream) {
upstreamSignal!.addEventListener("abort", () => stream.error(upstreamSignal!.reason));
queueMicrotask(() => controller.abort());
} }), { headers: { "Content-Type": "image/png" } });
};
try {
const response = await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fcancel.png&proxy=1&w=192", { signal: controller.signal }));
expect(upstreamSignal?.aborted).toBe(true);
expect(response.status).toBe(499);
} finally { globalThis.fetch = previousFetch; }
});
test("uses Qiniu image processing for direct canvas previews", () => {
expect(
buildImageProxyUrl("https://yybb.top/uploads/result.png", {
width: 1200,
quality: 80,
format: "webp",
})
).toBe(
"https://yybb.top/uploads/result.png?imageView2/2/w/1200/format/webp/q/80"
);
});
test("optimizes legacy first-party upload URLs without changing their identity", () => {
expect(
buildImageProxyUrl("https://bbyy.ai/uploads/result.png", {
width: 960,
quality: 88,
format: "webp",
})
).toBe(
"https://bbyy.ai/uploads/result.png?imageView2/2/w/960/format/webp/q/88"
);
});
test("does not overwrite an existing storage query", () => {
const url = "https://yybb.top/uploads/result.png?imageView2/2/w/400";
expect(buildImageProxyUrl(url, { width: 1200, format: "webp" })).toBe(url);
});
test("keeps unrelated first-party URLs out of the Qiniu path", () => {
const result = buildImageProxyUrl("https://bbyy.ai/logo.png", {
delivery: "proxy",
width: 320,
});
expect(result).toContain("/api/image-proxy?");
});
test("original downloads use an explicit same-origin proxy even for trusted signed CDN URLs", () => {
const source = "https://yybb.top/uploads/result.png?token=example&expires=123";
const result = new URL(buildImageProxyUrl(source, { delivery: "original" }), "https://app.example");
expect(result.pathname).toBe("/api/image-proxy");
expect(result.searchParams.get("url")).toBe(source);
expect(result.searchParams.get("original")).toBe("1");
expect(result.searchParams.has("w")).toBe(false);
expect(buildImageProxyUrl("data:image/png;base64,AA==", { delivery: "original" })).toBe("data:image/png;base64,AA==");
expect(buildImageProxyUrl("blob:example", { delivery: "original" })).toBe("blob:example");
expect(buildImageProxyUrl("/api/canvas/file/1", { delivery: "original" })).toBe("/api/canvas/file/1");
const nested = `/api/image-proxy?url=${encodeURIComponent(source)}&w=320&f=webp`;
expect(new URL(buildImageProxyUrl(nested, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(source);
expect(buildImageProxyUrl(nested, { delivery: "original" })).not.toContain("w=320");
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { origin: "https://ai.yydd.ai" } } });
try {
const sameOrigin = `https://ai.yydd.ai${nested}`;
expect(new URL(buildImageProxyUrl(sameOrigin, { delivery: "original" }), "https://ai.yydd.ai").searchParams.get("url")).toBe(source);
const external = `https://other.example${nested}`;
expect(new URL(buildImageProxyUrl(external, { delivery: "original" }), "https://ai.yydd.ai").searchParams.get("url")).toBe(external);
} finally {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("original proxy preserves JPEG bytes and ICC and does not reuse transformed or stale responses", async () => {
const source = "https://public.example/original.jpg?signature=abc";
const jpeg = await sharp({ create: { width: 12, height: 12, channels: 3, background: "#345678" } })
.withIccProfile("srgb").jpeg().toBuffer();
const newer = await sharp({ create: { width: 12, height: 12, channels: 3, background: "#123456" } })
.withIccProfile("srgb").jpeg().toBuffer();
expect((await sharp(jpeg).metadata()).icc).toBeTruthy();
const previousFetch = globalThis.fetch;
let body = jpeg;
globalThis.fetch = async () => new Response(body, { headers: { "Content-Type": "image/jpeg" } });
try {
const transformedUrl = new URL("http://localhost/api/image-proxy");
transformedUrl.searchParams.set("url", source);
transformedUrl.searchParams.set("proxy", "1");
const transformed = await GET(new NextRequest(transformedUrl));
expect(Buffer.from(await transformed.arrayBuffer()).equals(jpeg)).toBe(false);
const rawUrl = new URL("http://localhost/api/image-proxy");
rawUrl.searchParams.set("url", source);
rawUrl.searchParams.set("original", "1");
const first = await GET(new NextRequest(rawUrl));
const downloaded = Buffer.from(await first.arrayBuffer());
expect(downloaded.equals(jpeg)).toBe(true);
expect((await sharp(downloaded).metadata()).icc).toEqual((await sharp(jpeg).metadata()).icc);
expect(first.headers.get("content-type")).toBe("image/jpeg");
expect(first.headers.get("cache-control")).toBe("private, no-store");
expect(first.headers.get("content-disposition")).toBe("attachment");
expect(first.headers.get("x-content-type-options")).toBe("nosniff");
body = newer;
const next = await GET(new NextRequest(rawUrl, { headers: { "If-None-Match": first.headers.get("etag") || "" } }));
expect(next.status).toBe(200);
expect(Buffer.from(await next.arrayBuffer()).equals(newer)).toBe(true);
} finally {
globalThis.fetch = previousFetch;
}
});
test("rejects processed storage URLs without removing signatures", () => {
const qiniu = "https://yybb.top/a.jpg?imageView2/2/w/320&token=signed";
const aliyun = "https://bucket.oss-cn.aliyuncs.com/a.jpg?x-oss-process=image%2Fresize%2Cw_320&signature=signed";
for (const source of [qiniu, aliyun, `/api/image-proxy?url=${encodeURIComponent(qiniu)}&w=300`]) {
expect(() => buildImageProxyUrl(source, { delivery: "original" })).toThrow("无法确认原图");
}
const signed = "https://yybb.top/a.jpg?token=example%2Fvalue&expires=123";
expect(new URL(buildImageProxyUrl(signed, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(signed);
});
test("original proxy rejects private hosts, conflicting transforms and non-image content", async () => {
const previousFetch = globalThis.fetch;
let fetchCount = 0;
globalThis.fetch = async () => { fetchCount++; return new Response("not an image", { headers: { "Content-Type": "text/html" } }); };
try {
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=http%3A%2F%2F192.168.1.1%2Fsecret&original=1"))).status).toBe(403);
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1&w=200"))).status).toBe(400);
expect(fetchCount).toBe(0);
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1"))).status).toBe(422);
} finally {
globalThis.fetch = previousFetch;
}
});
test("original proxy returns PNG and WebP data without format conversion", async () => {
const originalFetch = globalThis.fetch;
try {
for (const format of ["png", "webp"] as const) {
const image = sharp({ create: { width: 4, height: 4, channels: 4, background: "#11447780" } });
const data = await (format === "png" ? image.png() : image.webp()).toBuffer();
globalThis.fetch = async () => new Response(data, { headers: { "Content-Type": `image/${format}` } });
const url = new URL("http://localhost/api/image-proxy");
url.searchParams.set("url", `https://public.example/original.${format}`);
url.searchParams.set("original", "1");
const response = await GET(new NextRequest(url));
expect(response.headers.get("content-type")).toBe(`image/${format}`);
expect(Buffer.from(await response.arrayBuffer()).equals(data)).toBe(true);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("download filename extension follows the original image MIME", () => {
expect(getImageDownloadExtension("image/png")).toBe("png");
expect(getImageDownloadExtension("image/jpeg; charset=binary")).toBe("jpg");
expect(getImageDownloadExtension("image/webp")).toBe("webp");
expect(getImageDownloadExtension("image/avif")).toBe("avif");
expect(getImageDownloadExtension("application/octet-stream")).toBe("");
});
});