304 lines
12 KiB
JavaScript
304 lines
12 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import http from "node:http";
|
|
import { createServer, normalizeObsidianMarkdown, triggerEleventyBuild } from "../server.js";
|
|
|
|
const TEST_SECRET = "super-secret-test-token";
|
|
|
|
test("Obsidian normalization: preserves linked note header fragments", () => {
|
|
const normalized = normalizeObsidianMarkdown(
|
|
"See [[a note#header one]] and [[another note#Second Header|the other section]]."
|
|
);
|
|
|
|
assert.match(
|
|
normalized,
|
|
/\[a note#header one\]\(\/posts\/a-note\/#header-one\)/,
|
|
"A wikilink header must become a URL fragment on the linked note"
|
|
);
|
|
assert.match(
|
|
normalized,
|
|
/\[the other section\]\(\/posts\/another-note\/#second-header\)/,
|
|
"Aliased wikilinks must retain their linked-note header fragment"
|
|
);
|
|
});
|
|
|
|
test("Ingestion API: rejects requests without proper Bearer token", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
|
process.env.API_SECRET = TEST_SECRET;
|
|
|
|
const server = createServer(tmpDir, tmpPosts);
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const port = server.address().port;
|
|
|
|
try {
|
|
// 1. Missing Authorization header
|
|
const resNoAuth = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ slug: "test", content: "test" })
|
|
});
|
|
assert.strictEqual(resNoAuth.status, 401, "Missing auth header must return 401");
|
|
|
|
// 2. Invalid Bearer token
|
|
const resBadAuth = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer wrong-token"
|
|
},
|
|
body: JSON.stringify({ slug: "test", content: "test" })
|
|
});
|
|
assert.strictEqual(resBadAuth.status, 401, "Invalid token must return 401");
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Ingestion API: validates required fields and frontmatter schema", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
|
process.env.API_SECRET = TEST_SECRET;
|
|
|
|
const server = createServer(tmpDir, tmpPosts);
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const port = server.address().port;
|
|
|
|
try {
|
|
// Missing slug
|
|
const resNoSlug = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${TEST_SECRET}`
|
|
},
|
|
body: JSON.stringify({ content: "---\ntitle: 'Test'\ndate: 2026-09-14\n---\nHello" })
|
|
});
|
|
assert.strictEqual(resNoSlug.status, 400, "Missing slug must return 400");
|
|
|
|
// Missing required frontmatter title
|
|
const resNoTitle = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${TEST_SECRET}`
|
|
},
|
|
body: JSON.stringify({
|
|
slug: "no-title-post",
|
|
content: "---\ndate: 2026-09-14\n---\nHello"
|
|
})
|
|
});
|
|
assert.strictEqual(resNoTitle.status, 400, "Missing frontmatter title must return 400");
|
|
|
|
// Missing required frontmatter date or created
|
|
const resNoDate = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${TEST_SECRET}`
|
|
},
|
|
body: JSON.stringify({
|
|
slug: "no-date-post",
|
|
content: "---\ntitle: 'Valid Title'\n---\nHello"
|
|
})
|
|
});
|
|
assert.strictEqual(resNoDate.status, 400, "Missing frontmatter created/date must return 400");
|
|
|
|
// Valid with created instead of date
|
|
const resWithCreated = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${TEST_SECRET}`
|
|
},
|
|
body: JSON.stringify({
|
|
slug: "created-tag-post",
|
|
content: "---\ntitle: 'Valid Title'\ncreated: 2026-09-14\n---\nHello"
|
|
})
|
|
});
|
|
assert.strictEqual(resWithCreated.status, 200, "Valid post with created tag must return 200");
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Ingestion API: publishes markdown, normalizes wikilinks, saves assets, and handles delete", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
|
const tmpAssets = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-assets-"));
|
|
process.env.API_SECRET = TEST_SECRET;
|
|
|
|
const server = createServer(tmpDir, tmpPosts, tmpAssets);
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const port = server.address().port;
|
|
|
|
const rawMarkdown = `---
|
|
title: "Reverse Engineering Flock Safety"
|
|
created: 2026-09-14
|
|
tags: ["#security", "#firmware"]
|
|
excerpt: "A deep dive into firmware."
|
|
---
|
|
|
|
Check out [[other-post|Other Post]] and also [[simple-link]].
|
|
Here is an image: ![[camera-board.png]]
|
|
`;
|
|
|
|
// 1x1 transparent PNG base64
|
|
const sampleBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
|
|
|
try {
|
|
// Publish post
|
|
const resPublish = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${TEST_SECRET}`
|
|
},
|
|
body: JSON.stringify({
|
|
slug: "flock-firmware",
|
|
content: rawMarkdown,
|
|
assets: [
|
|
{
|
|
filename: "camera-board.png",
|
|
data: sampleBase64
|
|
}
|
|
]
|
|
})
|
|
});
|
|
|
|
assert.strictEqual(resPublish.status, 200, "Valid publish must return 200");
|
|
const json = await resPublish.json();
|
|
assert.strictEqual(json.status, "published");
|
|
assert.strictEqual(json.path, "/posts/flock-firmware/");
|
|
|
|
// Verify markdown file written and wikilinks normalized
|
|
const writtenPath = path.join(tmpPosts, "flock-firmware.md");
|
|
assert.ok(fs.existsSync(writtenPath), "Post markdown file must be written to disk");
|
|
|
|
const writtenContent = fs.readFileSync(writtenPath, "utf-8");
|
|
assert.match(writtenContent, /^layout:\s*layouts\/post\.njk$/m, "Layout must be injected into frontmatter when missing");
|
|
assert.match(writtenContent, /\[Other Post\]\(\/posts\/other-post\/\)/, "Wikilink with alias must be translated");
|
|
assert.match(writtenContent, /\[simple-link\]\(\/posts\/simple-link\/\)/, "Simple wikilink must be translated");
|
|
assert.match(writtenContent, /!\[camera-board\.png\]\(\/assets\/images\/posts\/camera-board\.png\)/, "Attachment wikilink must be translated");
|
|
|
|
// Verify asset image was written
|
|
const assetPath = path.join(tmpAssets, "camera-board.png");
|
|
assert.ok(fs.existsSync(assetPath), "Asset file must be saved");
|
|
|
|
// Create simulated compiled static post files in siteDir
|
|
const compiledPostDir = path.join(tmpDir, "posts", "flock-firmware");
|
|
fs.mkdirSync(compiledPostDir, { recursive: true });
|
|
const compiledPostHtml = path.join(compiledPostDir, "index.html");
|
|
fs.writeFileSync(compiledPostHtml, "<html><body>Simulated compiled post</body></html>");
|
|
const standalonePostHtml = path.join(tmpDir, "posts", "flock-firmware.html");
|
|
fs.writeFileSync(standalonePostHtml, "<html><body>Simulated standalone post</body></html>");
|
|
|
|
assert.ok(fs.existsSync(compiledPostHtml), "Simulated compiled post must exist before delete");
|
|
assert.ok(fs.existsSync(standalonePostHtml), "Simulated standalone post must exist before delete");
|
|
|
|
// Delete post
|
|
const resDelete = await fetch(`http://127.0.0.1:${port}/api/posts?slug=flock-firmware`, {
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `Bearer ${TEST_SECRET}`
|
|
}
|
|
});
|
|
assert.strictEqual(resDelete.status, 200, "Delete must return 200");
|
|
assert.ok(!fs.existsSync(writtenPath), "Post markdown file must be deleted");
|
|
assert.ok(!fs.existsSync(compiledPostDir), "Compiled post directory must be deleted from _site");
|
|
assert.ok(!fs.existsSync(standalonePostHtml), "Compiled standalone HTML must be deleted from _site");
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
|
fs.rmSync(tmpAssets, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Ingestion API: adversarial protection against path traversal in slug and DELETE validation", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
|
process.env.API_SECRET = TEST_SECRET;
|
|
|
|
const server = createServer(tmpDir, tmpPosts);
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const port = server.address().port;
|
|
|
|
try {
|
|
// 1. Path traversal in POST
|
|
const resTraversal = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${TEST_SECRET}`
|
|
},
|
|
body: JSON.stringify({
|
|
slug: "../../etc/evil",
|
|
content: "---\ntitle: 'Evil'\ndate: 2026-09-14\n---\nEvil content"
|
|
})
|
|
});
|
|
// Should sanitize or reject slug containing path traversal
|
|
if (resTraversal.status === 200) {
|
|
const evilPath = path.resolve(tmpPosts, "../../etc/evil.md");
|
|
assert.ok(!fs.existsSync(evilPath), "Must never write outside posts directory");
|
|
} else {
|
|
assert.strictEqual(resTraversal.status, 400, "Invalid slug must return 400");
|
|
}
|
|
|
|
// 2. DELETE without auth token
|
|
const resDeleteNoAuth = await fetch(`http://127.0.0.1:${port}/api/posts?slug=any-post`, {
|
|
method: "DELETE"
|
|
});
|
|
assert.strictEqual(resDeleteNoAuth.status, 401, "DELETE without Bearer token must return 401");
|
|
|
|
// 3. DELETE with missing slug parameter
|
|
const resDeleteNoSlug = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `Bearer ${TEST_SECRET}`
|
|
}
|
|
});
|
|
assert.strictEqual(resDeleteNoSlug.status, 400, "DELETE without slug parameter must return 400");
|
|
|
|
// 4. DELETE with invalid/empty slug
|
|
const resDeleteBadSlug = await fetch(`http://127.0.0.1:${port}/api/posts?slug=///`, {
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `Bearer ${TEST_SECRET}`
|
|
}
|
|
});
|
|
assert.strictEqual(resDeleteBadSlug.status, 400, "DELETE with invalid slug must return 400");
|
|
|
|
// 5. DELETE non-existent post succeeds gracefully (idempotent)
|
|
const resDeleteNonExistent = await fetch(`http://127.0.0.1:${port}/api/posts?slug=non-existent-post`, {
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `Bearer ${TEST_SECRET}`
|
|
}
|
|
});
|
|
assert.strictEqual(resDeleteNonExistent.status, 200, "DELETE non-existent post must return 200 idempotently");
|
|
const jsonNonExistent = await resDeleteNonExistent.json();
|
|
assert.strictEqual(jsonNonExistent.status, "deleted");
|
|
assert.strictEqual(jsonNonExistent.slug, "non-existent-post");
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Ingestion API: triggerEleventyBuild builds site and handles concurrency", async () => {
|
|
assert.strictEqual(typeof triggerEleventyBuild, "function");
|
|
// Trigger two concurrent calls to assert queuing does not crash or throw
|
|
const [b1, b2] = await Promise.all([triggerEleventyBuild(), triggerEleventyBuild()]);
|
|
assert.strictEqual(b1, undefined);
|
|
assert.strictEqual(b2, undefined);
|
|
});
|
|
|