224 lines
6.1 KiB
TypeScript
224 lines
6.1 KiB
TypeScript
import {
|
|
afterEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi
|
|
} from "vitest";
|
|
import {
|
|
mkdtemp,
|
|
mkdir,
|
|
rm,
|
|
writeFile
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
ApplicationRequestError,
|
|
createApplicationService
|
|
} from "../src/index.js";
|
|
|
|
let temporaryDirectory: string | undefined;
|
|
|
|
afterEach(async () => {
|
|
if (temporaryDirectory) {
|
|
await rm(temporaryDirectory, {
|
|
recursive: true,
|
|
force: true
|
|
});
|
|
}
|
|
temporaryDirectory = undefined;
|
|
});
|
|
|
|
async function createThemeFixture() {
|
|
temporaryDirectory = await mkdtemp(
|
|
join(tmpdir(), "md-to-pdf-application-")
|
|
);
|
|
const bundledRoot = join(temporaryDirectory, "bundled");
|
|
const localRoot = join(temporaryDirectory, "local");
|
|
const themeRoot = join(bundledRoot, "test-theme");
|
|
await mkdir(join(themeRoot, "fonts"), { recursive: true });
|
|
await mkdir(localRoot, { recursive: true });
|
|
await writeFile(
|
|
join(themeRoot, "theme.json"),
|
|
JSON.stringify({
|
|
manifestVersion: 1,
|
|
id: "test-theme",
|
|
name: "测试主题",
|
|
version: "1.0.0",
|
|
description: "共享应用服务测试主题",
|
|
author: "test",
|
|
license: "MIT",
|
|
entry: "theme.css",
|
|
domPreset: "typora",
|
|
defaultFontSize: "16px",
|
|
supportedFeatures: ["code", "table"],
|
|
bundled: true
|
|
}),
|
|
"utf8"
|
|
);
|
|
await writeFile(
|
|
join(themeRoot, "theme.css"),
|
|
[
|
|
"@font-face {",
|
|
" font-family: Test;",
|
|
" src: url('./fonts/test.woff2') format('woff2');",
|
|
"}",
|
|
"#write { font-family: Test; }"
|
|
].join("\n"),
|
|
"utf8"
|
|
);
|
|
await writeFile(
|
|
join(themeRoot, "fonts", "test.woff2"),
|
|
Buffer.from([0, 1, 2, 3])
|
|
);
|
|
return { bundledRoot, localRoot };
|
|
}
|
|
|
|
describe("共享应用服务", () => {
|
|
it("渲染安全 Markdown 文档", async () => {
|
|
const roots = await createThemeFixture();
|
|
const service = createApplicationService(roots);
|
|
|
|
const document = await service.render({
|
|
markdown: "# 文档\n\n<script>alert('xss')</script>",
|
|
language: "zh-CN"
|
|
});
|
|
|
|
expect(document.articleHtml).toContain('id="write"');
|
|
expect(document.articleHtml).not.toContain("<script");
|
|
expect(document.metadata.title).toBe("文档");
|
|
});
|
|
|
|
it("以稳定错误协议拒绝非法请求", async () => {
|
|
const roots = await createThemeFixture();
|
|
const service = createApplicationService(roots);
|
|
|
|
await expect(service.render({ markdown: 42 })).rejects.toEqual(
|
|
expect.objectContaining<ApplicationRequestError>({
|
|
statusCode: 400,
|
|
code: "INVALID_MARKDOWN"
|
|
})
|
|
);
|
|
});
|
|
|
|
it("解析上传素材与桌面文档目录中的相对图片", async () => {
|
|
const roots = await createThemeFixture();
|
|
const service = createApplicationService(roots);
|
|
const image = Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
|
]);
|
|
const assetRoot = join(temporaryDirectory!, "document");
|
|
await mkdir(join(assetRoot, "文档.assets"), { recursive: true });
|
|
await writeFile(join(assetRoot, "文档.assets", "本地.png"), image);
|
|
|
|
const local = await service.render(
|
|
{
|
|
markdown:
|
|
""
|
|
},
|
|
{ localRoot: assetRoot }
|
|
);
|
|
const uploaded = await service.render({
|
|
markdown: "",
|
|
resources: [
|
|
{
|
|
path: "文档.assets/网页.png",
|
|
data: image.toString("base64")
|
|
}
|
|
]
|
|
});
|
|
|
|
expect(local.articleHtml).toContain("data:image/png;base64,");
|
|
expect(uploaded.articleHtml).toContain("data:image/png;base64,");
|
|
expect(local.articleHtml).toContain("md-document-image");
|
|
expect(uploaded.warnings).toEqual([]);
|
|
});
|
|
|
|
it("列出主题并使用调用方提供的资源 URL", async () => {
|
|
const roots = await createThemeFixture();
|
|
const service = createApplicationService({
|
|
...roots,
|
|
createAssetUrl: (themeId, assetPath) =>
|
|
`mdpdf://theme/${themeId}/${assetPath}`
|
|
});
|
|
|
|
await expect(service.listThemes()).resolves.toEqual({
|
|
themes: [
|
|
expect.objectContaining({
|
|
id: "test-theme",
|
|
bundled: true,
|
|
source: "bundled"
|
|
})
|
|
]
|
|
});
|
|
await expect(
|
|
service.getThemeCss("test-theme")
|
|
).resolves.toContain(
|
|
"mdpdf://theme/test-theme/fonts/test.woff2"
|
|
);
|
|
});
|
|
|
|
it("安全读取主题二进制资源", async () => {
|
|
const roots = await createThemeFixture();
|
|
const service = createApplicationService(roots);
|
|
|
|
await expect(
|
|
service.getThemeAsset("test-theme", "fonts/test.woff2")
|
|
).resolves.toEqual({
|
|
contentType: "font/woff2",
|
|
content: Buffer.from([0, 1, 2, 3])
|
|
});
|
|
await expect(
|
|
service.getThemeAsset("test-theme", "../test.woff2")
|
|
).rejects.toThrow("主题资源路径不安全");
|
|
});
|
|
|
|
it("内置主题优先于同 ID 的旧本地副本", async () => {
|
|
const roots = await createThemeFixture();
|
|
const localThemeRoot = join(roots.localRoot, "test-theme");
|
|
await mkdir(localThemeRoot, { recursive: true });
|
|
await writeFile(
|
|
join(localThemeRoot, "theme.json"),
|
|
JSON.stringify({
|
|
manifestVersion: 1,
|
|
id: "test-theme",
|
|
name: "旧本地主题",
|
|
version: "local",
|
|
description: "迁移前的本地副本",
|
|
author: "test",
|
|
license: "local-only",
|
|
entry: "theme.css",
|
|
domPreset: "typora",
|
|
defaultFontSize: "16px",
|
|
supportedFeatures: ["code", "table"],
|
|
bundled: false
|
|
}),
|
|
"utf8"
|
|
);
|
|
await writeFile(
|
|
join(localThemeRoot, "theme.css"),
|
|
"#write { color: red; }",
|
|
"utf8"
|
|
);
|
|
const onWarning = vi.fn();
|
|
const service = createApplicationService({
|
|
...roots,
|
|
onWarning
|
|
});
|
|
|
|
await expect(service.listThemes()).resolves.toEqual({
|
|
themes: [
|
|
expect.objectContaining({
|
|
id: "test-theme",
|
|
name: "测试主题",
|
|
bundled: true
|
|
})
|
|
]
|
|
});
|
|
expect(onWarning).toHaveBeenCalledWith(
|
|
"已忽略与内置主题同 ID 的本地主题 test-theme"
|
|
);
|
|
});
|
|
});
|