Files
cfw-attachment/tests/attachment-worker.test.ts

611 lines
26 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import app from "../src/index";
import { makeEnv, sharedStores } from "./fakes";
const BASE = "https://attachment.local";
async function call(
method: string,
path: string,
env: unknown,
init: { body?: BodyInit; headers?: Record<string, string> } = {},
): Promise<Response> {
const headers: Record<string, string> = { ...init.headers };
// JSON body 校验要求 content-type: application/json(hono 的 json validator 按 content-type 取体)。
// 没有 body 的请求不带;有 body 但调用方显式给了 content-type 则尊重原值。
if (init.body !== undefined && !headers["content-type"] && !headers["Content-Type"]) {
headers["content-type"] = "application/json";
}
const request = new Request(`${BASE}${path}`, {
method,
body: init.body,
headers,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return app.fetch(request, env as any);
}
async function createAttachment(
env: unknown,
body: Record<string, unknown>,
): Promise<{ attachment: { id: string; status: string; user_id: string } }> {
const response = await call("POST", "/api/attachments", env, { body: JSON.stringify(body) });
expect(response.status).toBe(201);
return (await response.json()) as { attachment: { id: string; status: string; user_id: string } };
}
describe("cfw-attachment worker (core flow)", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("proxies /image without a session and forwards Cloudflare image options", async () => {
const stores = sharedStores();
const env = makeEnv(stores, null);
const origin = "https://cdn.example.com/photo.jpg";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("image-bytes", { status: 200, headers: { "content-type": "image/webp" } }),
);
const response = await call(
"GET",
`/image?url=${encodeURIComponent(origin)}&width=320&height=200&quality=70&fit=cover&format=auto&bg=white`,
env,
{ headers: { Accept: "image/webp" } },
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/webp");
expect(await response.text()).toBe("image-bytes");
expect(env.AUTH.fetch).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0][0]).toBe(origin);
expect(fetchSpy.mock.calls[0][1]).toEqual({
headers: { Accept: "image/avif, image/webp, image/*" },
cf: {
image: {
width: 320,
height: 200,
quality: 70,
fit: "cover",
format: "webp",
background: "white",
},
},
});
});
it("returns 400 for invalid /image requests before fetching upstream", async () => {
const stores = sharedStores();
const env = makeEnv(stores, null);
const fetchSpy = vi.spyOn(globalThis, "fetch");
const missingUrl = await call("GET", "/image", env);
expect(missingUrl.status).toBe(400);
expect(await missingUrl.json()).toEqual({ error: "Missing required parameter: url" });
const unsupported = await call("GET", `/image?url=${encodeURIComponent("https://cdn.example.com/vector.svg")}`, env);
expect(unsupported.status).toBe(400);
expect(await unsupported.json()).toEqual({
error: "Unsupported image format. Only JPEG, PNG, GIF, WebP are supported",
});
const invalidSize = await call("GET", `/image?url=${encodeURIComponent("https://cdn.example.com/photo.png")}&width=0`, env);
expect(invalidSize.status).toBe(400);
expect(await invalidSize.json()).toEqual({ error: "Invalid width. Must be a positive integer" });
expect(fetchSpy).not.toHaveBeenCalled();
expect(env.AUTH.fetch).not.toHaveBeenCalled();
});
it("creates an attachment for the current user and hides it from another user", async () => {
const stores = sharedStores();
const userA = makeEnv(stores, "user_a");
const userB = makeEnv(stores, "user_b");
const created = await createAttachment(userA, {
filename: "invoice.pdf",
content_type: "application/pdf",
});
expect(created.attachment.status).toBe("upload_pending");
expect(created.attachment.user_id).toBe("user_a");
const asOwner = await call("GET", `/api/attachments/${created.attachment.id}`, userA);
expect(asOwner.status).toBe(200);
const asOther = await call("GET", `/api/attachments/${created.attachment.id}`, userB);
expect(asOther.status).toBe(404);
expect(asOther.headers.get("content-type")).toBe("application/problem+json");
});
it("uploads content, finalizes the attachment, and downloads the same bytes", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const payload = "hello attachment bytes";
const created = await createAttachment(env, {
filename: "note.txt",
content_type: "text/plain",
});
const uploaded = await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, {
body: payload,
headers: { "content-length": String(new TextEncoder().encode(payload).byteLength) },
});
expect(uploaded.status).toBe(200);
const uploadedBody = (await uploaded.json()) as { attachment: { status: string; byte_size: number } };
expect(uploadedBody.attachment.status).toBe("uploaded");
expect(uploadedBody.attachment.byte_size).toBe(new TextEncoder().encode(payload).byteLength);
const finalized = await call("POST", `/api/attachments/${created.attachment.id}/finalize`, env);
expect(finalized.status).toBe(200);
expect(((await finalized.json()) as { attachment: { status: string } }).attachment.status).toBe("available");
const download = await call("GET", `/api/attachments/${created.attachment.id}/download`, env);
expect(download.status).toBe(200);
expect(download.headers.get("content-type")).toBe("text/plain");
expect(await download.text()).toBe(payload);
});
it("does not download before finalize", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const payload = "not yet ready";
const created = await createAttachment(env, { filename: "draft.txt", content_type: "text/plain" });
await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: payload });
const download = await call("GET", `/api/attachments/${created.attachment.id}/download`, env);
expect(download.status).toBe(409);
const problem = (await download.json()) as { code: string };
expect(problem.code).toBe("object-not-available");
});
it("soft deletes an attachment and removes it from detail reads", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const created = await createAttachment(env, { filename: "temp.txt", content_type: "text/plain" });
const deleted = await call("DELETE", `/api/attachments/${created.attachment.id}`, env);
expect(deleted.status).toBe(204);
const detail = await call("GET", `/api/attachments/${created.attachment.id}`, env);
expect(detail.status).toBe(404);
});
it("rejects uploads that exceed the configured size limit", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a", "3");
const created = await createAttachment(env, { filename: "big.bin", content_type: "application/octet-stream" });
const tooLarge = await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, {
body: "more than three bytes",
headers: { "content-length": "21" },
});
expect(tooLarge.status).toBe(413);
});
it("returns 401 when there is no session", async () => {
const stores = sharedStores();
const env = makeEnv(stores, null);
const response = await call("POST", "/api/attachments", env, {
body: JSON.stringify({ filename: "x.txt", content_type: "text/plain" }),
});
expect(response.status).toBe(401);
});
it("returns 422 problem+json when required fields are missing", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const response = await call("POST", "/api/attachments", env, { body: JSON.stringify({ content_type: "text/plain" }) });
expect(response.status).toBe(422);
expect(response.headers.get("content-type")).toBe("application/problem+json");
const problem = (await response.json()) as { code: string; detail: { issues: Array<{ path: string }> } };
expect(problem.code).toBe("validation-failed");
expect(problem.detail.issues.some((issue) => issue.path === "filename")).toBe(true);
});
it("serves an OpenAPI document at /openapi.json without a session", async () => {
const stores = sharedStores();
const env = makeEnv(stores, null);
const response = await call("GET", "/openapi.json", env);
expect(response.status).toBe(200);
const document = (await response.json()) as {
openapi: string;
tags?: Array<{ name: string; description?: string }>;
paths: Record<
string,
Record<
string,
{
tags?: string[];
summary?: string;
description?: string;
parameters?: Array<{
name: string;
in: string;
required?: boolean;
schema?: { type?: string; enum?: string[] };
}>;
}
>
>;
components?: { schemas?: Record<string, unknown> };
};
expect(document.openapi).toBe("3.1.0");
expect(document.tags).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "Attachments", description: expect.any(String) }),
expect.objectContaining({ name: "Categories", description: expect.any(String) }),
expect.objectContaining({ name: "Tags", description: expect.any(String) }),
expect.objectContaining({ name: "Public files", description: expect.any(String) }),
]),
);
expect(document.paths["/api/attachments"]).toBeTruthy();
expect(document.paths["/api/attachments/{id}/multipart"]).toBeTruthy();
expect(document.paths["/api/attachments/{id}/multipart/parts/{partNumber}"]).toBeTruthy();
expect(document.paths["/files/{id}"]).toBeTruthy();
expect(document.paths["/image"]).toBeTruthy();
expect(document.paths["/api/attachments"].post).toMatchObject({
tags: ["Attachments"],
summary: expect.any(String),
description: expect.any(String),
});
expect(document.paths["/api/attachment-categories"].get).toMatchObject({
tags: ["Categories"],
summary: expect.any(String),
description: expect.any(String),
});
expect(document.paths["/api/attachment-tags"].get).toMatchObject({
tags: ["Tags"],
summary: expect.any(String),
description: expect.any(String),
});
expect(document.paths["/files/{id}"].get).toMatchObject({
tags: ["Public files"],
summary: expect.any(String),
description: expect.any(String),
});
expect(document.paths["/image"].get).toMatchObject({
tags: ["Images"],
summary: expect.any(String),
description: expect.any(String),
});
expect(document.paths["/image"].get.parameters).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "url", in: "query", required: true, schema: expect.objectContaining({ type: "string" }) }),
expect.objectContaining({ name: "width", in: "query", schema: expect.objectContaining({ type: "integer" }) }),
expect.objectContaining({ name: "height", in: "query", schema: expect.objectContaining({ type: "integer" }) }),
expect.objectContaining({ name: "quality", in: "query", schema: expect.objectContaining({ type: "integer" }) }),
expect.objectContaining({
name: "format",
in: "query",
schema: expect.objectContaining({ enum: expect.arrayContaining(["avif", "webp", "jpeg", "jpg", "png", "gif", "auto"]) }),
}),
expect.objectContaining({
name: "fit",
in: "query",
schema: expect.objectContaining({ enum: expect.arrayContaining(["scale-down", "scale-up", "contain", "cover", "crop", "pad", "squeeze"]) }),
}),
expect.objectContaining({ name: "bg", in: "query", schema: expect.objectContaining({ type: "string" }) }),
]),
);
expect(document.components?.schemas?.Attachment).toBeTruthy();
});
});
async function makeAttachment(
env: unknown,
filename: string,
contentType = "text/plain",
): Promise<string> {
const created = await createAttachment(env, { filename, content_type: contentType });
return created.attachment.id;
}
async function jsonBody(response: Response): Promise<any> {
expect(response.status).toBeLessThan(300);
return response.json();
}
describe("cfw-attachment worker (search, categories, tags)", () => {
it("searches current-user attachments with page and pageSize", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
await makeAttachment(env, "one.txt");
await makeAttachment(env, "two.txt");
await makeAttachment(env, "three.txt");
const first = await jsonBody(await call("GET", "/api/attachments/search?page=1&pageSize=2", env));
expect(first.page).toBe(1);
expect(first.pageSize).toBe(2);
expect(first.total).toBe(3);
expect(first.items).toHaveLength(2);
const second = await jsonBody(await call("GET", "/api/attachments/search?page=2&pageSize=2", env));
expect(second.total).toBe(3);
expect(second.items).toHaveLength(1);
});
it("filters search by keyword, category id, and tag slug", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const category = await jsonBody(await call("POST", "/api/attachment-categories", env, { body: JSON.stringify({ name: "Invoices" }) })).then((b) => b.category);
const tag = await jsonBody(await call("POST", "/api/attachment-tags", env, { body: JSON.stringify({ name: "Urgent" }) })).then((b) => b.tag);
const tagged = await makeAttachment(env, "invoice-q1.pdf", "application/pdf");
await call("PATCH", `/api/attachments/${tagged}`, env, {
body: JSON.stringify({ categoryId: category.id, tagIds: [tag.id], description: "first quarter" }),
});
await makeAttachment(env, "photo.png", "image/png");
const byKeyword = await jsonBody(await call("GET", "/api/attachments/search?q=invoice", env));
expect(byKeyword.total).toBe(1);
expect(byKeyword.items[0].id).toBe(tagged);
const byCategory = await jsonBody(await call("GET", `/api/attachments/search?categoryId=${category.id}`, env));
expect(byCategory.total).toBe(1);
expect(byCategory.items[0].id).toBe(tagged);
const byTag = await jsonBody(await call("GET", `/api/attachments/search?tag=${tag.slug}`, env));
expect(byTag.total).toBe(1);
expect(byTag.items[0].id).toBe(tagged);
});
it("does not return another user's attachments in search", async () => {
const stores = sharedStores();
const userA = makeEnv(stores, "user_a");
const userB = makeEnv(stores, "user_b");
await makeAttachment(userA, "secret.txt");
const result = await jsonBody(await call("GET", "/api/attachments/search", userB));
expect(result.total).toBe(0);
expect(result.items).toEqual([]);
});
it("creates, updates, lists, and deletes user-scoped categories", async () => {
const stores = sharedStores();
const userA = makeEnv(stores, "user_a");
const userB = makeEnv(stores, "user_b");
const created = await call("POST", "/api/attachment-categories", userA, { body: JSON.stringify({ name: "Receipts" }) });
expect(created.status).toBe(201);
const category = (await created.json()) as { category: { id: string; name: string; slug: string } };
expect(category.category.slug).toBe("receipts");
const updated = await jsonBody(await call("PATCH", `/api/attachment-categories/${category.category.id}`, userA, { body: JSON.stringify({ name: "Invoices" }) }));
expect(updated.category.name).toBe("Invoices");
const listedByOwner = await jsonBody(await call("GET", "/api/attachment-categories", userA));
expect(listedByOwner.categories).toHaveLength(1);
expect(listedByOwner.categories[0].id).toBe(category.category.id);
const listedByOther = await jsonBody(await call("GET", "/api/attachment-categories", userB));
expect(listedByOther.categories).toEqual([]);
const deletedByOther = await call("DELETE", `/api/attachment-categories/${category.category.id}`, userB);
expect(deletedByOther.status).toBe(404);
const deleted = await call("DELETE", `/api/attachment-categories/${category.category.id}`, userA);
expect(deleted.status).toBe(204);
const afterDelete = await jsonBody(await call("GET", "/api/attachment-categories", userA));
expect(afterDelete.categories).toEqual([]);
});
it("creates, updates, lists, and deletes user-scoped tags", async () => {
const stores = sharedStores();
const userA = makeEnv(stores, "user_a");
const userB = makeEnv(stores, "user_b");
const created = await call("POST", "/api/attachment-tags", userA, { body: JSON.stringify({ name: "Important" }) });
expect(created.status).toBe(201);
const tag = (await created.json()) as { tag: { id: string; name: string; slug: string } };
expect(tag.tag.slug).toBe("important");
const updated = await jsonBody(await call("PATCH", `/api/attachment-tags/${tag.tag.id}`, userA, { body: JSON.stringify({ name: "Critical" }) }));
expect(updated.tag.name).toBe("Critical");
const listedByOwner = await jsonBody(await call("GET", "/api/attachment-tags", userA));
expect(listedByOwner.tags).toHaveLength(1);
const listedByOther = await jsonBody(await call("GET", "/api/attachment-tags", userB));
expect(listedByOther.tags).toEqual([]);
const deletedByOther = await call("DELETE", `/api/attachment-tags/${tag.tag.id}`, userB);
expect(deletedByOther.status).toBe(404);
const deleted = await call("DELETE", `/api/attachment-tags/${tag.tag.id}`, userA);
expect(deleted.status).toBe(204);
const afterDelete = await jsonBody(await call("GET", "/api/attachment-tags", userA));
expect(afterDelete.tags).toEqual([]);
});
it("rejects duplicate category slugs for the same user", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const first = await call("POST", "/api/attachment-categories", env, { body: JSON.stringify({ name: "Shared" }) });
expect(first.status).toBe(201);
const duplicate = await call("POST", "/api/attachment-categories", env, { body: JSON.stringify({ name: "Shared" }) });
expect(duplicate.status).toBe(409);
});
});
async function initMultipart(env: unknown, id: string): Promise<{ upload_id: string }> {
const response = await call("POST", `/api/attachments/${id}/multipart`, env);
expect(response.status).toBe(200);
return (await response.json()) as { upload_id: string };
}
async function uploadPartOk(
env: unknown,
id: string,
partNumber: number,
body: BodyInit,
): Promise<{ part_number: number; etag: string; byte_size: number }> {
const response = await call("PUT", `/api/attachments/${id}/multipart/parts/${partNumber}`, env, { body });
expect(response.status).toBe(200);
return (await response.json()) as { part_number: number; etag: string; byte_size: number };
}
async function finalizeOk(env: unknown, id: string): Promise<{ attachment: { status: string; byte_size: number; etag: string | null; public_url: string | null } }> {
const response = await call("POST", `/api/attachments/${id}/finalize`, env);
expect(response.status).toBe(200);
return response.json();
}
describe("cfw-attachment worker (multipart & public files)", () => {
it("uploads parts, finalizes, and the assembled object matches the concatenated bytes", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const created = await createAttachment(env, { filename: "big.txt", content_type: "text/plain" });
const id = created.attachment.id;
const minPart = 5 * 1024 * 1024;
const bigPart = "a".repeat(minPart); // 非末片必须 >= 5MiB(R2 约束)
const tail = "-END";
const init = await initMultipart(env, id);
expect(init.upload_id).toBeTruthy();
await uploadPartOk(env, id, 1, bigPart);
await uploadPartOk(env, id, 2, tail);
const finalized = await finalizeOk(env, id);
expect(finalized.attachment.status).toBe("available");
expect(finalized.attachment.byte_size).toBe(minPart + tail.length);
expect(finalized.attachment.etag).toBeTruthy();
// 默认 private → 不返回 public_url
expect(finalized.attachment.public_url).toBeNull();
const download = await call("GET", `/api/attachments/${id}/download`, env);
expect(download.headers.get("content-type")).toBe("text/plain");
const text = await download.text();
expect(text.length).toBe(minPart + tail.length);
expect(text.endsWith(tail)).toBe(true);
expect(text.slice(0, minPart)).toBe(bigPart);
});
it("re-uploading the same part number keeps the latest bytes", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const id = (await createAttachment(env, { filename: "p.txt", content_type: "text/plain" })).attachment.id;
await initMultipart(env, id);
await uploadPartOk(env, id, 1, "first");
await uploadPartOk(env, id, 1, "second");
await finalizeOk(env, id);
const download = await call("GET", `/api/attachments/${id}/download`, env);
expect(await download.text()).toBe("second");
});
it("rejects finalize when a non-final part is below the minimum part size", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const id = (await createAttachment(env, { filename: "p.txt", content_type: "text/plain" })).attachment.id;
await initMultipart(env, id);
await uploadPartOk(env, id, 1, "tiny"); // 非末片且 < 5MiB
await uploadPartOk(env, id, 2, "x");
const finalize = await call("POST", `/api/attachments/${id}/finalize`, env);
expect(finalize.status).toBe(422);
const problem = (await finalize.json()) as { code: string; detail: { part_number: number } };
expect(problem.code).toBe("multipart-part-too-small");
expect(problem.detail.part_number).toBe(1);
});
it("aborts a multipart upload and lets a single-shot upload take over", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const id = (await createAttachment(env, { filename: "p.txt", content_type: "text/plain" })).attachment.id;
await initMultipart(env, id);
await uploadPartOk(env, id, 1, "part-data");
const abort = await call("DELETE", `/api/attachments/${id}/multipart`, env);
expect(abort.status).toBe(204);
// abort 后再传片 → 409(无活动会话)
const partAfter = await call("PUT", `/api/attachments/${id}/multipart/parts/1`, env, { body: "x" });
expect(partAfter.status).toBe(409);
// 改走单次上传 → finalize 成功
await call("PUT", `/api/attachments/${id}/content`, env, { body: "single-shot" });
const finalized = await finalizeOk(env, id);
expect(finalized.attachment.status).toBe("available");
const download = await call("GET", `/api/attachments/${id}/download`, env);
expect(await download.text()).toBe("single-shot");
});
it("GET /files/:id serves public/shared files with the right content-type and 404s otherwise", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const id = (await createAttachment(env, { filename: "img.png", content_type: "image/png", visibility: "public" })).attachment.id;
await call("PUT", `/api/attachments/${id}/content`, env, { body: "PNGDATA" });
await finalizeOk(env, id);
const publicFile = await call("GET", `/files/${id}`, env);
expect(publicFile.status).toBe(200);
expect(publicFile.headers.get("content-type")).toBe("image/png");
expect(publicFile.headers.get("content-disposition")).toContain("inline");
expect(await publicFile.text()).toBe("PNGDATA");
// 转 private → 404
await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ visibility: "private" }) });
expect((await call("GET", `/files/${id}`, env)).status).toBe(404);
// 转回 shared → 200
await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ visibility: "shared" }) });
expect((await call("GET", `/files/${id}`, env)).status).toBe(200);
// 未 finalize → 404
const pending = (await createAttachment(env, { filename: "u.txt", content_type: "text/plain", visibility: "public" })).attachment.id;
expect((await call("GET", `/files/${pending}`, env)).status).toBe(404);
});
it("exposes public_url for available public/shared attachments and omits it for private", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const expected = (id: string) => `https://attachment.local/files/${id}`;
const id = (await createAttachment(env, { filename: "doc.pdf", content_type: "application/pdf", visibility: "public" })).attachment.id;
await call("PUT", `/api/attachments/${id}/content`, env, { body: "PDF" });
const finalized = await finalizeOk(env, id);
expect(finalized.attachment.public_url).toBe(expected(id));
const privateView = await jsonBody(await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ visibility: "private" }) }));
expect(privateView.attachment.public_url).toBeNull();
const sharedView = await jsonBody(await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ visibility: "shared" }) }));
expect(sharedView.attachment.public_url).toBe(expected(id));
const search = await jsonBody(await call("GET", "/api/attachments/search", env));
expect(search.items[0].public_url).toBe(expected(id));
});
it("init refuses a second active multipart session", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const id = (await createAttachment(env, { filename: "p.txt", content_type: "text/plain" })).attachment.id;
await initMultipart(env, id);
const second = await call("POST", `/api/attachments/${id}/multipart`, env);
expect(second.status).toBe(409);
expect(((await second.json()) as { code: string }).code).toBe("multipart-active");
});
});