Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions apps/web/__tests__/api/artifacts/artifacts-import.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { POST } from "~/app/api/artifacts/route";
import { db } from "~/server/db";

const mockRequireWorkspaceContext = jest.fn();

jest.mock("~/lib/require-workspace-context", () => ({
...jest.requireActual("~/lib/require-workspace-context"),
requireWorkspaceContext: () => mockRequireWorkspaceContext(),
}));

// Rate limiting is exercised by its own tests; here it just passes through.
jest.mock("~/lib/rate-limit-middleware", () => ({
withRateLimit: (_req: Request, _preset: unknown, handler: () => Promise<Response>) => handler(),
}));

const mockAssertPublicHttpUrl = jest.fn();
const mockFetchPublicUrl = jest.fn();

jest.mock("~/server/security/url-guard", () => {
class UrlGuardError extends Error {}
return {
UrlGuardError,
assertPublicHttpUrl: (url: string) => mockAssertPublicHttpUrl(url),
fetchPublicUrl: (url: string, init?: RequestInit) => mockFetchPublicUrl(url, init),
};
});

jest.mock("~/server/db", () => ({
db: { insert: jest.fn() },
}));

function mockCtx() {
mockRequireWorkspaceContext.mockResolvedValue({
success: true,
data: {
authUserId: "user-123",
userPk: BigInt(7),
companyId: BigInt(42),
role: "owner",
status: "verified",
},
});
}

/** Captures the inserted values and echoes them back as the returned row. */
function mockInsert() {
let captured: Record<string, unknown> = {};
(db.insert as jest.Mock).mockReturnValue({
values: jest.fn().mockImplementation((values: Record<string, unknown>) => {
captured = values;
return {
returning: jest.fn().mockImplementation(() =>
Promise.resolve([
{
id: 1,
description: null,
updatedByUserId: null,
deletedAt: null,
starred: false,
createdAt: new Date(),
updatedAt: new Date(),
...captured,
},
])
),
};
}),
});
return () => captured;
}

function importRequest(body: unknown) {
return new Request("http://localhost/api/artifacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

describe("POST /api/artifacts", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("imports pasted content with a detected type and derived title", async () => {
mockCtx();
const inserted = mockInsert();

const response = await POST(
importRequest({
content: "<!DOCTYPE html><html><head><title>Churn Report</title></head></html>",
sourceUrl: "https://claude.ai/public/artifacts/abc",
})
);
const json = (await response.json()) as { artifact: { title: string } };

expect(response.status).toBe(201);
expect(json.artifact.title).toBe("Churn Report");
const values = inserted();
expect(values.companyId).toBe(BigInt(42));
expect(values.createdByUserId).toBe("user-123");
expect(values.artifactType).toBe("html");
expect(values.importMethod).toBe("paste");
expect(values.sourceUrl).toBe("https://claude.ai/public/artifacts/abc");
expect(values.contentHash).toMatch(/^[0-9a-f]{64}$/);
expect(mockFetchPublicUrl).not.toHaveBeenCalled();
});

it("refuses to fetch claude.ai share links with a structured 422", async () => {
mockCtx();

const response = await POST(
importRequest({
fetchFromUrl: true,
sourceUrl: "https://claude.ai/public/artifacts/abc",
})
);
const json = (await response.json()) as { code?: string };

expect(response.status).toBe(422);
expect(json.code).toBe("claude_share_link");
expect(mockFetchPublicUrl).not.toHaveBeenCalled();
expect(db.insert).not.toHaveBeenCalled();
});

it("fetches other URLs through the SSRF guard", async () => {
mockCtx();
const inserted = mockInsert();
mockAssertPublicHttpUrl.mockResolvedValue(new URL("https://example.com/page"));
mockFetchPublicUrl.mockResolvedValue(
new Response("<html><head><title>Docs</title></head></html>", {
status: 200,
headers: { "Content-Type": "text/html" },
})
);

const response = await POST(
importRequest({ fetchFromUrl: true, sourceUrl: "https://example.com/page" })
);

expect(response.status).toBe(201);
expect(mockAssertPublicHttpUrl).toHaveBeenCalledWith("https://example.com/page");
expect(mockFetchPublicUrl).toHaveBeenCalled();
expect(inserted().importMethod).toBe("url");
});

it("rejects a body with neither content nor a fetchable URL", async () => {
mockCtx();

const response = await POST(importRequest({ title: "No body" }));

expect(response.status).toBe(400);
expect(db.insert).not.toHaveBeenCalled();
});

it("passes the workspace-context failure response through", async () => {
mockRequireWorkspaceContext.mockResolvedValue({
success: false,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
});

const response = await POST(importRequest({ content: "# hi" }));

expect(response.status).toBe(401);
expect(db.insert).not.toHaveBeenCalled();
});
});
109 changes: 109 additions & 0 deletions apps/web/__tests__/lib/artifact-content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
artifactFileExtension,
artifactSearchText,
deriveArtifactTitle,
detectArtifactType,
isClaudeHostedUrl,
} from "~/lib/artifact-content";

describe("detectArtifactType", () => {
it("classifies full HTML documents by prefix", () => {
expect(detectArtifactType("<!DOCTYPE html><html><body>hi</body></html>")).toBe("html");
expect(detectArtifactType(" <html lang='en'><head></head></html>")).toBe("html");
});

it("classifies fragments with structural tags as html", () => {
expect(detectArtifactType('<div class="app"><script>alert(1)</script></div>')).toBe("html");
});

it("classifies svg, including with an xml prolog", () => {
expect(detectArtifactType('<svg viewBox="0 0 10 10"></svg>')).toBe("svg");
expect(detectArtifactType('<?xml version="1.0"?>\n<svg xmlns="…"></svg>')).toBe("svg");
});

it("classifies mermaid sources, including with an init directive", () => {
expect(detectArtifactType("flowchart TD\n A --> B")).toBe("mermaid");
expect(detectArtifactType("sequenceDiagram\n A->>B: hi")).toBe("mermaid");
expect(detectArtifactType("%%{init: {'theme':'dark'}}%%\ngraph LR\nA-->B")).toBe("mermaid");
});

it("classifies React components", () => {
expect(
detectArtifactType('import React from "react";\nexport default function App() {}')
).toBe("react");
expect(
detectArtifactType('export default function App() {\n return <Card title="x" />;\n}')
).toBe("react");
});

it("classifies plain code without markup", () => {
expect(detectArtifactType("def main():\n print('hi')")).toBe("code");
expect(detectArtifactType("const x = 1;\nconsole.log(x);")).toBe("code");
});

it("falls back to markdown for prose", () => {
expect(detectArtifactType("# Design review\n\nSome *notes* here.")).toBe("markdown");
expect(detectArtifactType("Just a paragraph of text.")).toBe("markdown");
});
});

describe("deriveArtifactTitle", () => {
it("uses <title> for html", () => {
expect(
deriveArtifactTitle(
"<html><head><title> Churn Dashboard </title></head></html>",
"html"
)
).toBe("Churn Dashboard");
});

it("falls back to <h1> for html without a title", () => {
expect(deriveArtifactTitle("<div><h1>Quarterly <em>Plan</em></h1></div>", "html")).toBe(
"Quarterly Plan"
);
});

it("uses the first heading for markdown", () => {
expect(deriveArtifactTitle("intro\n\n## Roadmap\n\nbody", "markdown")).toBe("Roadmap");
});

it("returns null when nothing usable exists", () => {
expect(deriveArtifactTitle("plain text", "markdown")).toBeNull();
expect(deriveArtifactTitle("<div>x</div>", "html")).toBeNull();
});
});

describe("isClaudeHostedUrl", () => {
it("matches claude.ai and subdomains", () => {
expect(isClaudeHostedUrl("https://claude.ai/public/artifacts/abc")).toBe(true);
expect(isClaudeHostedUrl("https://www.claude.ai/code/artifact/abc")).toBe(true);
expect(isClaudeHostedUrl("https://claude.site/artifacts/abc")).toBe(true);
});

it("rejects other hosts, including look-alikes", () => {
expect(isClaudeHostedUrl("https://example.com/page")).toBe(false);
expect(isClaudeHostedUrl("https://notclaude.ai/artifacts")).toBe(false);
expect(isClaudeHostedUrl("https://claude.ai.evil.com/x")).toBe(false);
expect(isClaudeHostedUrl("not a url")).toBe(false);
});
});

describe("artifactSearchText", () => {
it("strips scripts, styles, and tags", () => {
const text = artifactSearchText(
"<html><style>.a{color:red}</style><script>var x=1;</script><body><h1>Hello</h1> <p>world</p></body></html>"
);
expect(text).toBe("Hello world");
});
});

describe("artifactFileExtension", () => {
it("maps each type to a sensible extension", () => {
expect(artifactFileExtension("html")).toBe("html");
expect(artifactFileExtension("svg")).toBe("svg");
expect(artifactFileExtension("markdown")).toBe("md");
expect(artifactFileExtension("mermaid")).toBe("mmd");
expect(artifactFileExtension("react")).toBe("tsx");
expect(artifactFileExtension("code")).toBe("txt");
});
});
28 changes: 28 additions & 0 deletions apps/web/drizzle/20260830193613_claude_artifacts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
CREATE TABLE "pdr_ai_v2_claude_artifacts" (
"id" bigserial PRIMARY KEY NOT NULL,
"company_id" bigint NOT NULL,
"created_by_user_id" varchar(256) NOT NULL,
"updated_by_user_id" varchar(256),
"title" varchar(300) NOT NULL,
"description" text,
"folder" varchar(256) DEFAULT 'Unfiled' NOT NULL,
"artifact_type" varchar(32) DEFAULT 'html' NOT NULL,
"source_url" varchar(2048),
"import_method" varchar(32) DEFAULT 'paste' NOT NULL,
"content" text NOT NULL,
"size_bytes" integer DEFAULT 0 NOT NULL,
"content_hash" varchar(64) DEFAULT '' NOT NULL,
"search_text" text,
"starred" boolean DEFAULT false NOT NULL,
"deleted_at" timestamp with time zone,
"opened_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "pdr_ai_v2_claude_artifacts" ADD CONSTRAINT "pdr_ai_v2_claude_artifacts_company_id_pdr_ai_v2_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."pdr_ai_v2_company"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "claude_artifacts_company_idx" ON "pdr_ai_v2_claude_artifacts" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX "claude_artifacts_company_updated_idx" ON "pdr_ai_v2_claude_artifacts" USING btree ("company_id","updated_at");--> statement-breakpoint
CREATE INDEX "claude_artifacts_creator_idx" ON "pdr_ai_v2_claude_artifacts" USING btree ("created_by_user_id");--> statement-breakpoint
CREATE INDEX "claude_artifacts_folder_idx" ON "pdr_ai_v2_claude_artifacts" USING btree ("company_id","folder");--> statement-breakpoint
CREATE INDEX "claude_artifacts_deleted_idx" ON "pdr_ai_v2_claude_artifacts" USING btree ("deleted_at");
Loading
Loading