mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Render eight artifact card types from real task records and share them with editable Storybook stories. Preserve document review and media/file actions, and load bounded CSV previews on request. Co-Authored-By: Paperclip <noreply@paperclip.ing>
128 lines
4.2 KiB
TypeScript
128 lines
4.2 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
artifactNumber,
|
|
artifactPreviewUrl,
|
|
artifactUrl,
|
|
CSV_PREVIEW_MAX_BYTES,
|
|
loadArtifactCsv,
|
|
parseArtifactCsv,
|
|
} from "./artifact-card-data";
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
describe("artifact metadata", () => {
|
|
it("does not turn absent or invalid counts into zero", () => {
|
|
for (const value of [undefined, null, "12", -1, Infinity, NaN])
|
|
expect(artifactNumber({ additions: value }, "additions")).toBeNull();
|
|
expect(artifactNumber({ additions: 0 }, "additions")).toBe(0);
|
|
});
|
|
it("loads optional thumbnails only through local authenticated attachment routes", () => {
|
|
for (const value of [
|
|
"https://example.com/tracker.png",
|
|
"http://127.0.0.1/private",
|
|
"//evil.test/track",
|
|
"/api/companies",
|
|
"/api/attachments/a/content?redirect=1",
|
|
])
|
|
expect(artifactPreviewUrl(value)).toBe("");
|
|
expect(artifactPreviewUrl("/api/attachments/file-1/content")).toBe(
|
|
"/api/attachments/file-1/content",
|
|
);
|
|
});
|
|
it("permits web and root-relative URLs only", () => {
|
|
for (const url of [
|
|
"javascript:alert(1)",
|
|
"//evil.test",
|
|
"/\\evil.test",
|
|
"data:text/html,hello",
|
|
"https:\n//evil.test",
|
|
])
|
|
expect(artifactUrl(url)).toBe("");
|
|
expect(artifactUrl("/api/attachments/a/content")).toBe(
|
|
"/api/attachments/a/content",
|
|
);
|
|
expect(artifactUrl("https://example.com/path?q=1")).toBe(
|
|
"https://example.com/path?q=1",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("CSV artifact preview", () => {
|
|
it("reads quoted commas, escaped quotes, embedded newlines, CRLF, BOM and empty cells", () => {
|
|
expect(
|
|
parseArtifactCsv(
|
|
'\uFEFFName,Notes,Count\r\n"Smith, Jo","a ""quote""\nand line",0\r\nOther,,2\r\n',
|
|
),
|
|
).toEqual({
|
|
columns: ["Name", "Notes", "Count"],
|
|
rows: [
|
|
["Smith, Jo", 'a "quote"\nand line', "0"],
|
|
["Other", "", "2"],
|
|
],
|
|
truncated: false,
|
|
});
|
|
});
|
|
it("bounds rows, columns and bytes and rejects malformed quoting", () => {
|
|
const preview = parseArtifactCsv(
|
|
"Name\n" + Array.from({ length: 202 }, (_, i) => `${i}`).join("\n"),
|
|
);
|
|
expect(preview.rows).toHaveLength(200);
|
|
expect(preview.truncated).toBe(true);
|
|
expect(() => parseArtifactCsv(Array(51).fill("x").join(","))).toThrow(
|
|
"too many columns",
|
|
);
|
|
expect(() =>
|
|
parseArtifactCsv("x".repeat(CSV_PREVIEW_MAX_BYTES + 1)),
|
|
).toThrow("too large");
|
|
expect(() => parseArtifactCsv('Name\n"unfinished')).toThrow(
|
|
"could not be previewed",
|
|
);
|
|
expect(() => parseArtifactCsv('Name\n"closed"extra')).toThrow(
|
|
"could not be previewed",
|
|
);
|
|
expect(parseArtifactCsv("")).toEqual({
|
|
columns: [],
|
|
rows: [],
|
|
truncated: false,
|
|
});
|
|
});
|
|
it("never fetches remote or arbitrary local URLs", async () => {
|
|
const fetcher = vi.fn();
|
|
vi.stubGlobal("fetch", fetcher);
|
|
for (const path of [
|
|
"https://example.com/data.csv",
|
|
"/api/companies",
|
|
"/api/attachments/a/content?redirect=yes",
|
|
])
|
|
await expect(loadArtifactCsv(path)).rejects.toThrow("unavailable");
|
|
expect(fetcher).not.toHaveBeenCalled();
|
|
});
|
|
it("uses authenticated attachment content with no redirects", async () => {
|
|
const fetcher = vi.fn().mockResolvedValue(new Response("A,B\n1,2"));
|
|
vi.stubGlobal("fetch", fetcher);
|
|
const signal = new AbortController().signal;
|
|
expect(
|
|
(await loadArtifactCsv("/api/attachments/file-1/content", signal)).rows,
|
|
).toEqual([["1", "2"]]);
|
|
expect(fetcher).toHaveBeenCalledWith("/api/attachments/file-1/content", {
|
|
credentials: "same-origin",
|
|
redirect: "error",
|
|
signal,
|
|
});
|
|
});
|
|
it("stops oversized streamed files even when the server sends no content length", async () => {
|
|
const cancel = vi.fn();
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array(CSV_PREVIEW_MAX_BYTES + 1));
|
|
},
|
|
cancel,
|
|
});
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(stream)));
|
|
await expect(
|
|
loadArtifactCsv("/api/attachments/file-1/content"),
|
|
).rejects.toThrow("too large");
|
|
expect(cancel).toHaveBeenCalled();
|
|
});
|
|
});
|