import { afterEach, describe, expect, it } from "vitest"; import type { FastifyInstance } from "fastify"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildApp } from "../src/app.js"; import { createThemeRegistry } from "../src/theme-registry.js"; let app: FastifyInstance | undefined; let temporaryDirectory: string | undefined; function localThemeManifest( id: string, overrides: Record = {} ) { return { manifestVersion: 1, id, name: "本地测试主题", version: "local", description: "本地主题测试", author: "test", license: "local-only", entry: "theme.css", domPreset: "typora", defaultFontSize: "16px", supportedFeatures: ["code", "table"], bundled: false, ...overrides }; } afterEach(async () => { await app?.close(); if (temporaryDirectory) { await rm(temporaryDirectory, { recursive: true, force: true }); } app = undefined; temporaryDirectory = undefined; }); describe("预览 API", () => { it("渲染 Markdown 并返回安全文章结构", async () => { app = buildApp(); const response = await app.inject({ method: "POST", url: "/api/render", payload: { markdown: "# 文档\n\n" } }); expect(response.statusCode).toBe(200); const result = response.json(); expect(result.articleHtml).toContain('id="write"'); expect(result.articleHtml).not.toContain(" { app = buildApp(); const response = await app.inject({ method: "POST", url: "/api/render", payload: { markdown: 42 } }); expect(response.statusCode).toBe(400); expect(response.json().error).toBe("INVALID_MARKDOWN"); }); it("返回内置主题 CSS", async () => { app = buildApp(); const response = await app.inject({ method: "GET", url: "/api/themes/typora-like/css" }); expect(response.statusCode).toBe(200); expect(response.headers["content-type"]).toContain("text/css"); expect(response.body).toContain("#write"); }); it("发现本地主题并安全提供相对字体资源", async () => { temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-")); const themeDirectory = join(temporaryDirectory, "local-test"); await mkdir(join(themeDirectory, "partials", "fonts"), { recursive: true }); await writeFile( join(themeDirectory, "theme.json"), JSON.stringify( localThemeManifest("local-test", { base: "typora-base.css" }) ), "utf8" ); await writeFile( join(themeDirectory, "typora-base.css"), "/* base-layer */ table { width: 100%; border-collapse: collapse; }", "utf8" ); await writeFile( join(themeDirectory, "theme.css"), [ "@include-when-export url(https://example.com/font.css);", "@import \"partials/typography.css\";", "#write { font-family: LocalTest; }" ].join("\n"), "utf8" ); await writeFile( join(themeDirectory, "partials", "typography.css"), [ "/* imported-layer */", "@font-face { src: url('./fonts/test.woff2') format('woff2'); }" ].join("\n"), "utf8" ); await writeFile( join(themeDirectory, "partials", "fonts", "test.woff2"), Buffer.from([0, 1, 2, 3]) ); app = buildApp({ localThemeRoot: temporaryDirectory, logger: false }); const themesResponse = await app.inject({ method: "GET", url: "/api/themes" }); expect(themesResponse.statusCode).toBe(200); expect(themesResponse.json().themes).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "local-test", bundled: false, source: "local" }) ]) ); const cssResponse = await app.inject({ method: "GET", url: "/api/themes/local-test/css" }); expect(cssResponse.statusCode).toBe(200); expect(cssResponse.body).not.toContain("@include-when-export"); expect(cssResponse.body).toContain( "/api/themes/local-test/assets/partials/fonts/test.woff2" ); expect(cssResponse.body.indexOf("base-layer")).toBeLessThan( cssResponse.body.indexOf("imported-layer") ); expect(cssResponse.body.indexOf("imported-layer")).toBeLessThan( cssResponse.body.indexOf("font-family: LocalTest") ); const assetResponse = await app.inject({ method: "GET", url: "/api/themes/local-test/assets/partials/fonts/test.woff2" }); expect(assetResponse.statusCode).toBe(200); expect(assetResponse.headers["content-type"]).toContain("font/woff2"); expect(assetResponse.rawPayload).toEqual(Buffer.from([0, 1, 2, 3])); }); it("拒绝主题 CSS 循环导入", async () => { temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-")); const bundledRoot = join(temporaryDirectory, "bundled"); const localRoot = join(temporaryDirectory, "local"); const themeDirectory = join(localRoot, "cycle-test"); await mkdir(bundledRoot, { recursive: true }); await mkdir(themeDirectory, { recursive: true }); await writeFile( join(themeDirectory, "theme.json"), JSON.stringify(localThemeManifest("cycle-test")), "utf8" ); await writeFile( join(themeDirectory, "theme.css"), '@import "nested.css";', "utf8" ); await writeFile( join(themeDirectory, "nested.css"), '@import "theme.css";', "utf8" ); const registry = createThemeRegistry({ bundledRoot, localRoot }); await expect(registry.getCss("cycle-test")).rejects.toThrow( "主题 CSS 存在循环引用" ); }); it("拒绝主题 CSS 导入外部地址", async () => { temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-")); const bundledRoot = join(temporaryDirectory, "bundled"); const localRoot = join(temporaryDirectory, "local"); const themeDirectory = join(localRoot, "external-test"); await mkdir(bundledRoot, { recursive: true }); await mkdir(themeDirectory, { recursive: true }); await writeFile( join(themeDirectory, "theme.json"), JSON.stringify(localThemeManifest("external-test")), "utf8" ); await writeFile( join(themeDirectory, "theme.css"), '@import "https://example.com/theme.css";', "utf8" ); const registry = createThemeRegistry({ bundledRoot, localRoot }); await expect(registry.getCss("external-test")).rejects.toThrow( "主题 CSS 路径不安全" ); }); });