Merge remote-tracking branch 'public-gh/master' into paperclip-subissues
* public-gh/master: (51 commits) Use attachment-size limit for company logos Address Greptile company logo feedback Drop lockfile from PR branch Use asset-backed company logos fix: use appType "custom" for Vite dev server so worktree branding is applied docs: fix documentation drift — adapters, plugins, tech stack docs: update documentation for accuracy after plugin system launch chore: ignore superset artifacts Dark theme for CodeMirror code blocks in MDXEditor Remove duplicate @paperclipai/adapter-openclaw-gateway in server/package.json Fix code block styles with robust prose overrides Add Docker setup for untrusted PR review in isolated containers Fix org chart canvas height to fit viewport without scrolling Add doc-maintenance skill for periodic documentation accuracy audits Fix sidebar scrollbar: hide track background when not hovering Restyle markdown code blocks: dark background, smaller font, compact padding Add archive project button and filter archived projects from selectors fix: address review feedback — subscription cleanup, filter nullability, stale diagram fix: wire plugin event subscriptions from worker to host fix(ui): hide scrollbar track background when sidebar is not hovered ... # Conflicts: # packages/db/src/migrations/meta/0030_snapshot.json # packages/db/src/migrations/meta/_journal.json
This commit is contained in:
250
server/src/__tests__/assets.test.ts
Normal file
250
server/src/__tests__/assets.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
import { assetRoutes } from "../routes/assets.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
|
||||
const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() => ({
|
||||
createAssetMock: vi.fn(),
|
||||
getAssetByIdMock: vi.fn(),
|
||||
logActivityMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
assetService: vi.fn(() => ({
|
||||
create: createAssetMock,
|
||||
getById: getAssetByIdMock,
|
||||
})),
|
||||
logActivity: logActivityMock,
|
||||
}));
|
||||
|
||||
function createAsset() {
|
||||
const now = new Date("2026-01-01T00:00:00.000Z");
|
||||
return {
|
||||
id: "asset-1",
|
||||
companyId: "company-1",
|
||||
provider: "local",
|
||||
objectKey: "assets/abc",
|
||||
contentType: "image/png",
|
||||
byteSize: 40,
|
||||
sha256: "sha256-sample",
|
||||
originalFilename: "logo.png",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function createStorageService(contentType = "image/png"): StorageService {
|
||||
const putFile: StorageService["putFile"] = vi.fn(async (input: {
|
||||
companyId: string;
|
||||
namespace: string;
|
||||
originalFilename: string | null;
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
}) => {
|
||||
return {
|
||||
provider: "local_disk" as const,
|
||||
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
|
||||
contentType: contentType || input.contentType,
|
||||
byteSize: input.body.length,
|
||||
sha256: "sha256-sample",
|
||||
originalFilename: input.originalFilename,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
provider: "local_disk" as const,
|
||||
putFile,
|
||||
getObject: vi.fn(),
|
||||
headObject: vi.fn(),
|
||||
deleteObject: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(storage: ReturnType<typeof createStorageService>) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "board",
|
||||
source: "local_implicit",
|
||||
userId: "user-1",
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", assetRoutes({} as any, storage));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
afterEach(() => {
|
||||
createAssetMock.mockReset();
|
||||
getAssetByIdMock.mockReset();
|
||||
logActivityMock.mockReset();
|
||||
});
|
||||
|
||||
it("accepts PNG image uploads and returns an asset path", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = createApp(png);
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "goals")
|
||||
.attach("file", Buffer.from("png"), "logo.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
expect(createAssetMock).toHaveBeenCalledTimes(1);
|
||||
expect(png.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/goals",
|
||||
originalFilename: "logo.png",
|
||||
contentType: "image/png",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
|
||||
it("allows supported non-image attachments outside the company logo flow", async () => {
|
||||
const text = createStorageService("text/plain");
|
||||
const app = createApp(text);
|
||||
|
||||
createAssetMock.mockResolvedValue({
|
||||
...createAsset(),
|
||||
contentType: "text/plain",
|
||||
originalFilename: "note.txt",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "issues/drafts")
|
||||
.attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(text.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/issues/drafts",
|
||||
originalFilename: "note.txt",
|
||||
contentType: "text/plain",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/companies/:companyId/logo", () => {
|
||||
afterEach(() => {
|
||||
createAssetMock.mockReset();
|
||||
getAssetByIdMock.mockReset();
|
||||
logActivityMock.mockReset();
|
||||
});
|
||||
|
||||
it("accepts PNG logo uploads and returns an asset path", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = createApp(png);
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("png"), "logo.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
expect(createAssetMock).toHaveBeenCalledTimes(1);
|
||||
expect(png.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/companies",
|
||||
originalFilename: "logo.png",
|
||||
contentType: "image/png",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes SVG logo uploads before storing them", async () => {
|
||||
const svg = createStorageService("image/svg+xml");
|
||||
const app = createApp(svg);
|
||||
|
||||
createAssetMock.mockResolvedValue({
|
||||
...createAsset(),
|
||||
contentType: "image/svg+xml",
|
||||
originalFilename: "logo.svg",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach(
|
||||
"file",
|
||||
Buffer.from(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' onload='alert(1)'><script>alert(1)</script><a href='https://evil.example/'><circle cx='12' cy='12' r='10'/></a></svg>",
|
||||
),
|
||||
"logo.svg",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(svg.putFile).toHaveBeenCalledTimes(1);
|
||||
const stored = (svg.putFile as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
|
||||
expect(stored.contentType).toBe("image/svg+xml");
|
||||
expect(stored.originalFilename).toBe("logo.svg");
|
||||
const body = stored.body.toString("utf8");
|
||||
expect(body).toContain("<svg");
|
||||
expect(body).toContain("<circle");
|
||||
expect(body).not.toContain("<script");
|
||||
expect(body).not.toContain("onload=");
|
||||
expect(body).not.toContain("https://evil.example/");
|
||||
});
|
||||
|
||||
it("allows logo uploads within the general attachment limit", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = createApp(png);
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const file = Buffer.alloc(150 * 1024, "a");
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", file, "within-limit.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("rejects logo files larger than the general attachment limit", async () => {
|
||||
const app = createApp(createStorageService());
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const file = Buffer.alloc(MAX_ATTACHMENT_BYTES + 1, "a");
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", file, "too-large.png");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe(`Image exceeds ${MAX_ATTACHMENT_BYTES} bytes`);
|
||||
});
|
||||
|
||||
it("rejects unsupported image types", async () => {
|
||||
const app = createApp(createStorageService("text/plain"));
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("not an image"), "note.txt");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Unsupported image type: text/plain");
|
||||
expect(createAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects SVG image uploads that cannot be sanitized", async () => {
|
||||
const app = createApp(createStorageService("image/svg+xml"));
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("not actually svg"), "logo.svg");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("SVG could not be sanitized");
|
||||
expect(createAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user