mirror of
https://github.com/makeplane/plane.git
synced 2026-08-29 10:08:51 +02:00
isSafeImageSrc only validated the URL handed to <Image src>, before render. The actual fetch happens inside @react-pdf/image's fetchRemoteFile, which follows redirects by default and never re-consults the guard on the redirect target — so a URL on an ordinary public host could 302 to an internal address (cloud metadata, a Docker service name, loopback) and be fetched anyway, no DNS control required. Close it the same way imageComponent asset images are already handled: pre-fetch image-node srcs before rendering starts, instead of handing a raw URL to the renderer. Added fetchImageSrcSafely, which fetches with redirect: "manual" and re-validates every hop against isSafeImageSrc, capped at 5 redirects. pdf-export.service.ts now extracts external image srcs alongside asset ids and resolves both into the same data-URI map before the synchronous render pass runs; node-renderers.tsx's image renderer looks up that pre-resolved value instead of taking the raw src. Also switched the pre-existing per-asset URL resolution loop in processImages to Promise.all, since it was already independent per asset and needed to satisfy the same lint rule the new redirect-following code does (which, unlike that loop, is genuinely sequential by nature). Documented the residual DNS-rebinding TOCTOU that remains on the final, non-redirect hop: isSafeImageSrc judges a hostname once, and the actual fetch resolves DNS again independently, so a name that changes address between those two lookups is still unguarded. Re-validating every redirect hop closes the far more easily exploited "one crafted HTTP response" gap; it does not add DNS pinning. Co-authored-by: Plane AI <noreply@plane.so>
808 lines
24 KiB
TypeScript
808 lines
24 KiB
TypeScript
/**
|
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
* See the LICENSE file for details.
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { PDFParse } from "pdf-parse";
|
|
import sharp from "sharp";
|
|
import { renderPlaneDocToPdfBuffer } from "@/lib/pdf";
|
|
import type { TipTapDocument, PDFExportMetadata } from "@/lib/pdf";
|
|
|
|
const PDF_HEADER = "%PDF-";
|
|
|
|
/** A tiny valid JPEG data URI, standing in for a pre-fetched/resolved image src. */
|
|
async function tinyJpegDataUri(): Promise<string> {
|
|
const buffer = await sharp({
|
|
create: { width: 2, height: 2, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
|
})
|
|
.jpeg()
|
|
.toBuffer();
|
|
return `data:image/jpeg;base64,${buffer.toString("base64")}`;
|
|
}
|
|
|
|
/**
|
|
* Helper to extract text content from a PDF buffer
|
|
*/
|
|
async function extractPdfText(buffer: Buffer): Promise<string> {
|
|
const uint8 = new Uint8Array(buffer);
|
|
const parser = new PDFParse(uint8);
|
|
const result = await parser.getText();
|
|
return result.pages.map((p) => p.text).join("\n");
|
|
}
|
|
|
|
describe("PDF Rendering Integration", () => {
|
|
describe("renderPlaneDocToPdfBuffer", () => {
|
|
it("should render empty document to valid PDF", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
|
|
expect(buffer).toBeInstanceOf(Buffer);
|
|
expect(buffer.length).toBeGreaterThan(0);
|
|
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
|
});
|
|
|
|
it("should render document with title and verify content", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Hello World" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc, {
|
|
title: "Test Document",
|
|
});
|
|
|
|
expect(buffer).toBeInstanceOf(Buffer);
|
|
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
|
|
|
const text = await extractPdfText(buffer);
|
|
expect(text).toContain("Hello World");
|
|
// Title is rendered in PDF content when provided
|
|
expect(text).toContain("Test Document");
|
|
});
|
|
|
|
it("should render heading nodes and verify text", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "heading",
|
|
attrs: { level: 1 },
|
|
content: [{ type: "text", text: "Main Heading" }],
|
|
},
|
|
{
|
|
type: "heading",
|
|
attrs: { level: 2 },
|
|
content: [{ type: "text", text: "Subheading" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("Main Heading");
|
|
expect(text).toContain("Subheading");
|
|
});
|
|
|
|
it("should render paragraph with text and verify content", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "This is a test paragraph with some content." }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("This is a test paragraph with some content.");
|
|
});
|
|
|
|
it("should render bullet list with all items", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "bulletList",
|
|
content: [
|
|
{
|
|
type: "listItem",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "First item" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "listItem",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Second item" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "listItem",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Third item" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("First item");
|
|
expect(text).toContain("Second item");
|
|
expect(text).toContain("Third item");
|
|
// Bullet points should be present
|
|
expect(text).toContain("•");
|
|
});
|
|
|
|
it("should render ordered list with numbers", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "orderedList",
|
|
content: [
|
|
{
|
|
type: "listItem",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Step one" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "listItem",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Step two" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("Step one");
|
|
expect(text).toContain("Step two");
|
|
// Numbers should be present
|
|
expect(text).toMatch(/1\./);
|
|
expect(text).toMatch(/2\./);
|
|
});
|
|
|
|
it("should render task list with task text", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "taskList",
|
|
content: [
|
|
{
|
|
type: "taskItem",
|
|
attrs: { checked: true },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Completed task" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "taskItem",
|
|
attrs: { checked: false },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Pending task" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("Completed task");
|
|
expect(text).toContain("Pending task");
|
|
});
|
|
|
|
it("should render code block with code content", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "codeBlock",
|
|
content: [
|
|
{ type: "text", text: "const greeting = 'Hello';\n" },
|
|
{ type: "text", text: "console.log(greeting);" },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("const greeting");
|
|
expect(text).toContain("console.log");
|
|
});
|
|
|
|
it("should render blockquote with quoted text", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "blockquote",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "This is a quoted text." }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("This is a quoted text.");
|
|
});
|
|
|
|
it("should render table with all cell content", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "table",
|
|
content: [
|
|
{
|
|
type: "tableRow",
|
|
content: [
|
|
{
|
|
type: "tableHeader",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Header 1" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tableHeader",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Header 2" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tableRow",
|
|
content: [
|
|
{
|
|
type: "tableCell",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Cell 1" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tableCell",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Cell 2" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("Header 1");
|
|
expect(text).toContain("Header 2");
|
|
expect(text).toContain("Cell 1");
|
|
expect(text).toContain("Cell 2");
|
|
});
|
|
|
|
it("should render horizontal rule with surrounding text", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Before rule" }],
|
|
},
|
|
{ type: "horizontalRule" },
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "After rule" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("Before rule");
|
|
expect(text).toContain("After rule");
|
|
});
|
|
|
|
it("should render text with marks (bold, italic) preserving content", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "Normal " },
|
|
{
|
|
type: "text",
|
|
text: "bold",
|
|
marks: [{ type: "bold" }],
|
|
},
|
|
{ type: "text", text: " and " },
|
|
{
|
|
type: "text",
|
|
text: "italic",
|
|
marks: [{ type: "italic" }],
|
|
},
|
|
{ type: "text", text: " text." },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("Normal");
|
|
expect(text).toContain("bold");
|
|
expect(text).toContain("italic");
|
|
expect(text).toContain("text.");
|
|
});
|
|
|
|
it("should render link marks with link text", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "Click " },
|
|
{
|
|
type: "text",
|
|
text: "here",
|
|
marks: [{ type: "link", attrs: { href: "https://example.com" } }],
|
|
},
|
|
{ type: "text", text: " to visit." },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("Click");
|
|
expect(text).toContain("here");
|
|
expect(text).toContain("to visit");
|
|
});
|
|
});
|
|
|
|
describe("page options", () => {
|
|
it("should support different page sizes and verify content renders", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Page size test content" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const a4Buffer = await renderPlaneDocToPdfBuffer(doc, { pageSize: "A4" });
|
|
const letterBuffer = await renderPlaneDocToPdfBuffer(doc, { pageSize: "LETTER" });
|
|
|
|
const a4Text = await extractPdfText(a4Buffer);
|
|
const letterText = await extractPdfText(letterBuffer);
|
|
|
|
expect(a4Text).toContain("Page size test content");
|
|
expect(letterText).toContain("Page size test content");
|
|
// Different page sizes should produce different PDF sizes
|
|
expect(a4Buffer.length).not.toBe(letterBuffer.length);
|
|
});
|
|
|
|
it("should support landscape orientation and verify content", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Landscape content here" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const portraitBuffer = await renderPlaneDocToPdfBuffer(doc, { pageOrientation: "portrait" });
|
|
const landscapeBuffer = await renderPlaneDocToPdfBuffer(doc, { pageOrientation: "landscape" });
|
|
|
|
const portraitText = await extractPdfText(portraitBuffer);
|
|
const landscapeText = await extractPdfText(landscapeBuffer);
|
|
|
|
expect(portraitText).toContain("Landscape content here");
|
|
expect(landscapeText).toContain("Landscape content here");
|
|
expect(portraitBuffer.length).not.toBe(landscapeBuffer.length);
|
|
});
|
|
|
|
it("should include author metadata in PDF", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Document content" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc, {
|
|
author: "Test Author",
|
|
});
|
|
|
|
// Verify PDF is valid and contains content
|
|
expect(buffer).toBeInstanceOf(Buffer);
|
|
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
|
// Author metadata is embedded in PDF info dict (checked via raw bytes)
|
|
const pdfString = buffer.toString("latin1");
|
|
expect(pdfString).toContain("/Author");
|
|
});
|
|
|
|
it("should include subject metadata in PDF", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Document content" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc, {
|
|
subject: "Technical Documentation",
|
|
});
|
|
|
|
// Verify PDF is valid
|
|
expect(buffer).toBeInstanceOf(Buffer);
|
|
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
|
// Subject metadata is embedded in PDF info dict
|
|
const pdfString = buffer.toString("latin1");
|
|
expect(pdfString).toContain("/Subject");
|
|
});
|
|
});
|
|
|
|
describe("metadata rendering", () => {
|
|
it("should render user mentions with resolved display name", async () => {
|
|
const metadata: PDFExportMetadata = {
|
|
userMentions: [{ id: "user-123", display_name: "John Doe" }],
|
|
};
|
|
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "Hello " },
|
|
{
|
|
type: "mention",
|
|
attrs: {
|
|
entity_name: "user_mention",
|
|
entity_identifier: "user-123",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc, { metadata });
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("Hello");
|
|
expect(text).toContain("John Doe");
|
|
});
|
|
});
|
|
|
|
describe("complex documents", () => {
|
|
it("should render a full document with mixed content and verify all sections", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "heading",
|
|
attrs: { level: 1 },
|
|
content: [{ type: "text", text: "Project Overview" }],
|
|
},
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "This document describes the " },
|
|
{ type: "text", text: "key features", marks: [{ type: "bold" }] },
|
|
{ type: "text", text: " of the project." },
|
|
],
|
|
},
|
|
{
|
|
type: "heading",
|
|
attrs: { level: 2 },
|
|
content: [{ type: "text", text: "Features" }],
|
|
},
|
|
{
|
|
type: "bulletList",
|
|
content: [
|
|
{
|
|
type: "listItem",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Feature A - Core functionality" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "listItem",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Feature B - Advanced options" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "heading",
|
|
attrs: { level: 2 },
|
|
content: [{ type: "text", text: "Code Example" }],
|
|
},
|
|
{
|
|
type: "codeBlock",
|
|
content: [{ type: "text", text: "function hello() {\n return 'world';\n}" }],
|
|
},
|
|
{
|
|
type: "blockquote",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Important: Review before deployment." }],
|
|
},
|
|
],
|
|
},
|
|
{ type: "horizontalRule" },
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "End of document." }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc, {
|
|
title: "Project Overview",
|
|
author: "Development Team",
|
|
subject: "Technical Documentation",
|
|
});
|
|
|
|
const text = await extractPdfText(buffer);
|
|
|
|
// Verify metadata is embedded in PDF
|
|
const pdfString = buffer.toString("latin1");
|
|
expect(pdfString).toContain("/Title");
|
|
expect(pdfString).toContain("/Author");
|
|
expect(pdfString).toContain("/Subject");
|
|
|
|
// Verify all content sections are present
|
|
expect(text).toContain("Project Overview");
|
|
expect(text).toContain("This document describes the");
|
|
expect(text).toContain("key features");
|
|
expect(text).toContain("Features");
|
|
expect(text).toContain("Feature A - Core functionality");
|
|
expect(text).toContain("Feature B - Advanced options");
|
|
expect(text).toContain("Code Example");
|
|
expect(text).toContain("function hello");
|
|
expect(text).toContain("Important: Review before deployment");
|
|
expect(text).toContain("End of document");
|
|
});
|
|
|
|
it("should render deeply nested lists with all levels", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "bulletList",
|
|
content: [
|
|
{
|
|
type: "listItem",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Level 1" }],
|
|
},
|
|
{
|
|
type: "bulletList",
|
|
content: [
|
|
{
|
|
type: "listItem",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Level 2" }],
|
|
},
|
|
{
|
|
type: "bulletList",
|
|
content: [
|
|
{
|
|
type: "listItem",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Level 3" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("Level 1");
|
|
expect(text).toContain("Level 2");
|
|
expect(text).toContain("Level 3");
|
|
});
|
|
});
|
|
|
|
describe("noAssets option", () => {
|
|
it("should render text but skip images when noAssets is true", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "image",
|
|
attrs: { src: "https://example.com/image.png" },
|
|
},
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Text after image" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc, { noAssets: true });
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("Text after image");
|
|
});
|
|
});
|
|
|
|
describe("image node SSRF pre-resolution", () => {
|
|
// The `image` renderer never fetches its own src or hands the raw URL to
|
|
// @react-pdf/image at render time — pdf-export.service.ts pre-fetches every
|
|
// external `image`-node src (through the redirect-safe path) before rendering
|
|
// starts and hands the result in via metadata.resolvedImageUrls, the same way
|
|
// it already does for `imageComponent` assets. These tests exercise that
|
|
// renderer-level contract directly, without going through the fetch pipeline.
|
|
|
|
it("renders the pre-resolved data URI when metadata carries a resolved entry for the src", async () => {
|
|
const src = "https://images.example.com/photo.png";
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [{ type: "image", attrs: { src } }],
|
|
};
|
|
const metadata: PDFExportMetadata = { resolvedImageUrls: { [src]: await tinyJpegDataUri() } };
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc, { metadata });
|
|
|
|
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
|
});
|
|
|
|
it("renders the placeholder, never the raw src, when the src has no pre-resolved entry", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [{ type: "image", attrs: { src: "https://images.example.com/never-fetched.png" } }],
|
|
};
|
|
|
|
// No metadata at all: the renderer must fall back to the placeholder rather
|
|
// than passing the raw external URL straight to <Image>.
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("Image unavailable");
|
|
});
|
|
|
|
it("renders the placeholder for a src that failed pre-resolution (e.g. blocked by SSRF checks)", async () => {
|
|
const src = "http://169.254.169.254/latest/meta-data/";
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [{ type: "image", attrs: { src } }],
|
|
};
|
|
// A failed pre-fetch (unsafe src, unsafe redirect target, or network error)
|
|
// simply omits the key from resolvedImageUrls — it never carries a fallback
|
|
// to the raw src.
|
|
const metadata: PDFExportMetadata = { resolvedImageUrls: {} };
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc, { metadata });
|
|
const text = await extractPdfText(buffer);
|
|
|
|
expect(text).toContain("Image unavailable");
|
|
});
|
|
|
|
it("still renders inline data: URIs directly, with no pre-fetch entry required", async () => {
|
|
const doc: TipTapDocument = {
|
|
type: "doc",
|
|
content: [{ type: "image", attrs: { src: await tinyJpegDataUri() } }],
|
|
};
|
|
|
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
|
|
|
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
|
});
|
|
});
|
|
});
|