diff --git a/src/io/conversion/manager.test.ts b/src/io/conversion/manager.test.ts new file mode 100644 index 0000000..54a96cf --- /dev/null +++ b/src/io/conversion/manager.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { ConversionManager, ConversionTimeoutError } from "./manager"; +import type { ConversionRequest, ConversionResult, ModelConverter } from "./types"; + +function createMockConverter( + delayMs: number, + result: ConversionResult, +): ModelConverter { + return { + id: "mock", + sourceExts: [".test"], + targetExt: "glb", + getCacheKey: async () => "mock-cache-key", + convert: async () => { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return result; + }, + }; +} + +const successResult: ConversionResult = { + outputPath: "/out/mock.glb", + outputExt: "glb", + fromCache: false, + warnings: [], +}; + +describe("ConversionManager", () => { + it("converts a supported file", async () => { + const manager = new ConversionManager(); + manager.registerConverter(createMockConverter(0, successResult)); + const result = await manager.convert({ + sourcePath: "/in/model.test", + sourceExt: ".test", + targetExt: "glb", + }); + expect(result.outputPath).toBe("/out/mock.glb"); + }); + + it("throws when no converter is registered", async () => { + const manager = new ConversionManager(); + await expect( + manager.convert({ + sourcePath: "/in/model.unknown", + sourceExt: ".unknown", + targetExt: "glb", + }), + ).rejects.toThrow("No converter registered"); + }); + + it("times out a hanging conversion", async () => { + const manager = new ConversionManager(); + manager.registerConverter(createMockConverter(10_000, successResult)); + await expect( + manager.convert({ + sourcePath: "/in/model.test", + sourceExt: ".test", + targetExt: "glb", + timeoutMs: 50, + }), + ).rejects.toThrow(ConversionTimeoutError); + }); + + it("deduplicates concurrent conversions for the same source", async () => { + const manager = new ConversionManager(); + let calls = 0; + manager.registerConverter({ + id: "mock", + sourceExts: [".test"], + targetExt: "glb", + getCacheKey: async () => "key", + convert: async () => { + calls++; + await new Promise((resolve) => setTimeout(resolve, 20)); + return successResult; + }, + }); + const req: ConversionRequest = { + sourcePath: "/in/model.test", + sourceExt: ".test", + targetExt: "glb", + }; + const [a, b] = await Promise.all([manager.convert(req), manager.convert(req)]); + expect(a).toBe(b); + expect(calls).toBe(1); + }); +}); diff --git a/src/utils/escape-html.test.ts b/src/utils/escape-html.test.ts new file mode 100644 index 0000000..f22b3ae --- /dev/null +++ b/src/utils/escape-html.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { escapeHtml } from "./escape-html"; + +describe("escapeHtml", () => { + it("escapes HTML special characters", () => { + expect(escapeHtml("")).toBe( + "<script>alert('xss')</script>", + ); + }); + + it("escapes double quotes", () => { + expect(escapeHtml('value="test"')).toBe("value="test""); + }); + + it("leaves plain text unchanged", () => { + expect(escapeHtml("hello world")).toBe("hello world"); + }); + + it("handles empty strings", () => { + expect(escapeHtml("")).toBe(""); + }); +}); diff --git a/src/view/workbench/remote-draft-normalizer.ts b/src/view/workbench/remote-draft-normalizer.ts new file mode 100644 index 0000000..e9f6081 --- /dev/null +++ b/src/view/workbench/remote-draft-normalizer.ts @@ -0,0 +1,53 @@ +import { escapeHtml } from "../../utils/escape-html"; +import type { RemoteDraftResult } from "../../domain/models"; + +const MAX_REMOTE_DRAFT_FIELD_LENGTH = 8000; + +function sanitizeRemoteDraftString(value: string): string { + const trimmed = value.trim(); + if (trimmed.length > MAX_REMOTE_DRAFT_FIELD_LENGTH) { + return escapeHtml(trimmed.slice(0, MAX_REMOTE_DRAFT_FIELD_LENGTH)) + "…"; + } + return escapeHtml(trimmed); +} + +export function normalizeRemoteDraftResult(value: unknown): RemoteDraftResult | null { + if (!value || typeof value !== "object") { + return null; + } + const record = value as Record; + const summaryRaw = typeof record.summary === "string" ? record.summary.trim() : ""; + const summary = sanitizeRemoteDraftString(summaryRaw); + if (!summary) { + return null; + } + const sections = Array.isArray(record.sections) + ? record.sections.flatMap((section) => { + if (!section || typeof section !== "object") return []; + const sectionRecord = section as Record; + const headingRaw = typeof sectionRecord.heading === "string" ? sectionRecord.heading.trim() : ""; + const bodyRaw = typeof sectionRecord.body === "string" ? sectionRecord.body.trim() : ""; + const heading = sanitizeRemoteDraftString(headingRaw); + const body = sanitizeRemoteDraftString(bodyRaw); + return heading && body ? [{ heading, body }] : []; + }) + : undefined; + const suggestedTags = Array.isArray(record.suggestedTags) + ? record.suggestedTags + .filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0) + .map((tag) => sanitizeRemoteDraftString(tag)) + : undefined; + const warnings = Array.isArray(record.warnings) + ? record.warnings + .filter((warning): warning is string => typeof warning === "string" && warning.trim().length > 0) + .map((warning) => sanitizeRemoteDraftString(warning)) + : undefined; + return { + title: typeof record.title === "string" ? sanitizeRemoteDraftString(record.title) : undefined, + summary, + sections, + suggestedTags, + warnings, + model: typeof record.model === "string" ? sanitizeRemoteDraftString(record.model) : undefined, + }; +} diff --git a/src/view/workbench/remote-draft.test.ts b/src/view/workbench/remote-draft.test.ts new file mode 100644 index 0000000..efbdc5a --- /dev/null +++ b/src/view/workbench/remote-draft.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { normalizeRemoteDraftResult } from "./remote-draft-normalizer"; + +describe("normalizeRemoteDraftResult", () => { + it("returns null for missing summary", () => { + expect(normalizeRemoteDraftResult({ title: "test" })).toBeNull(); + }); + + it("sanitizes HTML in remote draft fields", () => { + const result = normalizeRemoteDraftResult({ + title: "", + summary: "Summary with ", + sections: [{ heading: "heading", body: "

body

" }], + suggestedTags: [""], + warnings: [""], + model: "", + }); + expect(result).not.toBeNull(); + expect(result?.title).toBe("<img src=x onerror=alert(1)>"); + expect(result?.summary).toBe("Summary with <script>bad()</script>"); + expect(result?.sections?.[0].heading).toBe("<b>heading</b>"); + expect(result?.sections?.[0].body).toBe("<p>body</p>"); + expect(result?.suggestedTags?.[0]).toBe("<tag>"); + expect(result?.warnings?.[0]).toBe("<alert>"); + expect(result?.model).toBe("<model>"); + }); + + it("caps overly long fields", () => { + const long = "a".repeat(10_000); + const result = normalizeRemoteDraftResult({ summary: long }); + expect(result?.summary.length).toBeLessThanOrEqual(8001); + }); +}); diff --git a/src/view/workbench/remote-draft.ts b/src/view/workbench/remote-draft.ts index 0fe8755..56a2dac 100644 --- a/src/view/workbench/remote-draft.ts +++ b/src/view/workbench/remote-draft.ts @@ -1,6 +1,6 @@ import { requestUrl } from "obsidian"; -import { escapeHtml } from "../../utils/escape-html"; +import { normalizeRemoteDraftResult } from "./remote-draft-normalizer"; import type { AnalysisDraftingInput, PluginSettings, @@ -123,57 +123,6 @@ export function createRemoteDraftDecision( }; } -const MAX_REMOTE_DRAFT_FIELD_LENGTH = 8000; - -function sanitizeRemoteDraftString(value: string): string { - const trimmed = value.trim(); - if (trimmed.length > MAX_REMOTE_DRAFT_FIELD_LENGTH) { - return escapeHtml(trimmed.slice(0, MAX_REMOTE_DRAFT_FIELD_LENGTH)) + "…"; - } - return escapeHtml(trimmed); -} - -function normalizeRemoteDraftResult(value: unknown): RemoteDraftResult | null { - if (!value || typeof value !== "object") { - return null; - } - const record = value as Record; - const summaryRaw = typeof record.summary === "string" ? record.summary.trim() : ""; - const summary = sanitizeRemoteDraftString(summaryRaw); - if (!summary) { - return null; - } - const sections = Array.isArray(record.sections) - ? record.sections.flatMap((section) => { - if (!section || typeof section !== "object") return []; - const sectionRecord = section as Record; - const headingRaw = typeof sectionRecord.heading === "string" ? sectionRecord.heading.trim() : ""; - const bodyRaw = typeof sectionRecord.body === "string" ? sectionRecord.body.trim() : ""; - const heading = sanitizeRemoteDraftString(headingRaw); - const body = sanitizeRemoteDraftString(bodyRaw); - return heading && body ? [{ heading, body }] : []; - }) - : undefined; - const suggestedTags = Array.isArray(record.suggestedTags) - ? record.suggestedTags - .filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0) - .map((tag) => sanitizeRemoteDraftString(tag)) - : undefined; - const warnings = Array.isArray(record.warnings) - ? record.warnings - .filter((warning): warning is string => typeof warning === "string" && warning.trim().length > 0) - .map((warning) => sanitizeRemoteDraftString(warning)) - : undefined; - return { - title: typeof record.title === "string" ? sanitizeRemoteDraftString(record.title) : undefined, - summary, - sections, - suggestedTags, - warnings, - model: typeof record.model === "string" ? sanitizeRemoteDraftString(record.model) : undefined, - }; -} - export async function requestRemoteDraft(decision: RemoteDraftDecision): Promise { if (!decision.enabled || !decision.endpoint || !decision.request) { return null; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..c1d4aee --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + environment: "node", + include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/**/*.d.ts"], + }, + }, +});