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:
Dotta
2026-03-16 16:02:37 -05:00
63 changed files with 5060 additions and 1054 deletions

View 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();
});
});