294 lines
12 KiB
JavaScript
294 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 { isPostHidden, isPostPublic, postComparator } from "../src/_utils/sort-posts.js";
|
|
import { createServer, normalizeObsidianMarkdown } from "../server.js";
|
|
|
|
test("Hidden Posts: isPostHidden accurately identifies hidden flags across data shapes", () => {
|
|
// 1. Explicit boolean true
|
|
assert.equal(isPostHidden({ data: { hidden: true } }), true, "Explicit boolean true in data");
|
|
assert.equal(isPostHidden({ hidden: true }), true, "Root level hidden true");
|
|
|
|
// 2. Truthy string representations (case-insensitive & trimmed)
|
|
assert.equal(isPostHidden({ data: { hidden: "true" } }), true, "String 'true'");
|
|
assert.equal(isPostHidden({ data: { hidden: "TRUE" } }), true, "Uppercase string 'TRUE'");
|
|
assert.equal(isPostHidden({ data: { hidden: " true " } }), true, "Whitespace-padded 'true'");
|
|
assert.equal(isPostHidden({ data: { hidden: "yes" } }), true, "String 'yes'");
|
|
assert.equal(isPostHidden({ data: { hidden: "YES" } }), true, "String 'YES'");
|
|
assert.equal(isPostHidden({ data: { hidden: "1" } }), true, "String '1'");
|
|
assert.equal(isPostHidden({ data: { hidden: 1 } }), true, "Numeric 1");
|
|
|
|
// 3. Falsy representations
|
|
assert.equal(isPostHidden({ data: { hidden: false } }), false, "Explicit boolean false");
|
|
assert.equal(isPostHidden({ data: { hidden: "false" } }), false, "String 'false'");
|
|
assert.equal(isPostHidden({ data: { hidden: "no" } }), false, "String 'no'");
|
|
assert.equal(isPostHidden({ data: { hidden: "0" } }), false, "String '0'");
|
|
assert.equal(isPostHidden({ data: { hidden: 0 } }), false, "Numeric 0");
|
|
assert.equal(isPostHidden({ data: { hidden: "" } }), false, "Empty string");
|
|
assert.equal(isPostHidden({ data: { hidden: null } }), false, "Null property");
|
|
assert.equal(isPostHidden({ data: {} }), false, "Missing hidden property");
|
|
assert.equal(isPostHidden({}), false, "Empty post object");
|
|
|
|
// 4. Adversarial edge cases
|
|
assert.equal(isPostHidden(null), false, "Null input");
|
|
assert.equal(isPostHidden(undefined), false, "Undefined input");
|
|
assert.equal(isPostHidden("unexpected-string"), false, "Primitive string input");
|
|
assert.equal(isPostHidden(12345), false, "Primitive number input");
|
|
assert.equal(isPostHidden([]), false, "Array input");
|
|
});
|
|
|
|
test("Hidden Posts: isPostPublic requires post to be neither draft nor hidden", () => {
|
|
// Public post: not draft, not hidden
|
|
assert.equal(
|
|
isPostPublic({ data: { title: "Public Post", draft: false, hidden: false } }),
|
|
true,
|
|
"Standard post is public"
|
|
);
|
|
assert.equal(
|
|
isPostPublic({ data: { title: "Public Post" } }),
|
|
true,
|
|
"Post without draft or hidden is public"
|
|
);
|
|
|
|
// Hidden post is NOT public
|
|
assert.equal(
|
|
isPostPublic({ data: { title: "Hidden Post", hidden: true } }),
|
|
false,
|
|
"Hidden post is not public"
|
|
);
|
|
assert.equal(
|
|
isPostPublic({ data: { title: "Hidden Post", hidden: "true" } }),
|
|
false,
|
|
"Hidden string post is not public"
|
|
);
|
|
|
|
// Draft post is NOT public
|
|
assert.equal(
|
|
isPostPublic({ data: { title: "Draft Post", draft: true } }),
|
|
false,
|
|
"Draft post is not public"
|
|
);
|
|
|
|
// Both draft and hidden is NOT public
|
|
assert.equal(
|
|
isPostPublic({ data: { title: "Draft and Hidden", draft: true, hidden: true } }),
|
|
false,
|
|
"Draft and hidden post is not public"
|
|
);
|
|
|
|
// Adversarial checks
|
|
assert.equal(isPostPublic(null), false, "Null is not public");
|
|
assert.equal(isPostPublic(undefined), false, "Undefined is not public");
|
|
assert.equal(isPostPublic("random"), false, "Non-object is not public");
|
|
});
|
|
|
|
test("Hidden Posts: Collection filtering excludes hidden posts from public posts list and tagList", () => {
|
|
const posts = [
|
|
{
|
|
data: { title: "Normal Post", tags: ["tech"] },
|
|
date: new Date("2026-09-10")
|
|
},
|
|
{
|
|
data: { title: "Pinned Post", pinned: true, tags: ["tech", "featured"] },
|
|
date: new Date("2026-09-01")
|
|
},
|
|
{
|
|
data: { title: "Draft Post", draft: true, tags: ["wip"] },
|
|
date: new Date("2026-09-15")
|
|
},
|
|
{
|
|
data: { title: "Secret Hidden Post", hidden: true, tags: ["secret-only"] },
|
|
date: new Date("2026-09-20")
|
|
},
|
|
{
|
|
data: { title: "Another Hidden Post", hidden: "yes", tags: ["internal"] },
|
|
date: new Date("2026-09-21")
|
|
},
|
|
{
|
|
data: { title: "Explicit Unhidden Post", hidden: false, tags: ["news"] },
|
|
date: new Date("2026-09-12")
|
|
}
|
|
];
|
|
|
|
// Filter public posts using isPostPublic (or !draft && !isPostHidden)
|
|
const publicPosts = posts.filter(isPostPublic).sort(postComparator);
|
|
|
|
const titles = publicPosts.map((p) => p.data.title);
|
|
assert.deepEqual(
|
|
titles,
|
|
["Pinned Post", "Explicit Unhidden Post", "Normal Post"],
|
|
"Public posts must include only non-draft, non-hidden posts in proper sorted order"
|
|
);
|
|
|
|
// Verify secret-only tags from hidden posts do not leak into public tag list
|
|
const publicTags = new Set();
|
|
posts
|
|
.filter(isPostPublic)
|
|
.forEach((item) => {
|
|
const tags = item.data.tags || [];
|
|
for (const t of tags) publicTags.add(t);
|
|
});
|
|
|
|
assert.ok(!publicTags.has("secret-only"), "Hidden post tag 'secret-only' must not appear in public tag list");
|
|
assert.ok(!publicTags.has("internal"), "Hidden post tag 'internal' must not appear in public tag list");
|
|
assert.ok(!publicTags.has("wip"), "Draft post tag 'wip' must not appear in public tag list");
|
|
assert.ok(publicTags.has("tech"), "Public post tag 'tech' must appear in public tag list");
|
|
assert.ok(publicTags.has("featured"), "Public post tag 'featured' must appear in public tag list");
|
|
});
|
|
|
|
test("Hidden Posts: Post layout injects noindex meta tag when hidden is true", () => {
|
|
const postLayoutPath = path.resolve("src/_includes/layouts/post.njk");
|
|
assert.ok(fs.existsSync(postLayoutPath), "post.njk must exist");
|
|
const postLayout = fs.readFileSync(postLayoutPath, "utf-8");
|
|
|
|
// Adversarial verification: hidden posts must instruct search crawlers not to index or follow
|
|
assert.match(
|
|
postLayout,
|
|
/noindex,\s*nofollow/i,
|
|
"post.njk must define 'noindex, nofollow' robots meta tag when hidden"
|
|
);
|
|
assert.match(
|
|
postLayout,
|
|
/{%\s*if\s+hidden\b/i,
|
|
"post.njk must conditionally check if post is hidden"
|
|
);
|
|
});
|
|
|
|
|
|
test("Hidden Posts: Obsidian normalization preserves hidden frontmatter property", () => {
|
|
const obsidianNote = `---
|
|
title: "Confidential System Blueprint"
|
|
created: 2026-09-23
|
|
tags: ["#architecture", "#secret"]
|
|
hidden: true
|
|
---
|
|
|
|
# Blueprint
|
|
Details for authorized eyes only.
|
|
`;
|
|
|
|
const normalized = normalizeObsidianMarkdown(obsidianNote);
|
|
assert.match(normalized, /hidden:\s*true/, "Normalized markdown must preserve 'hidden: true'");
|
|
assert.match(normalized, /title:\s*"Confidential System Blueprint"/, "Preserves title");
|
|
assert.match(normalized, /date:\s*2026-09-23/, "Bridges created to date");
|
|
assert.match(normalized, /layout:\s*layouts\/post\.njk/, "Adds post layout");
|
|
});
|
|
|
|
test("Hidden Posts: End-to-end static compilation and accessibility via direct link", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
|
|
|
try {
|
|
// 1. Create a public post and a hidden post
|
|
const publicPostContent = `---
|
|
title: "Public Announcement"
|
|
date: 2026-09-20
|
|
layout: layouts/post.njk
|
|
tags: ["news"]
|
|
excerpt: "This is a public announcement for everyone to read."
|
|
---
|
|
Public content visible to everyone.`;
|
|
|
|
const hiddenPostContent = `---
|
|
title: "Private Unlisted Memo"
|
|
date: 2026-09-22
|
|
layout: layouts/post.njk
|
|
tags: ["confidential"]
|
|
excerpt: "This is an unlisted memo meant for direct link access only."
|
|
hidden: true
|
|
---
|
|
Secret memo content available by link only.`;
|
|
|
|
fs.writeFileSync(path.join(tmpPosts, "2026-09-20-public-post.md"), publicPostContent, "utf-8");
|
|
fs.writeFileSync(path.join(tmpPosts, "2026-09-22-secret-memo.md"), hiddenPostContent, "utf-8");
|
|
|
|
// 2. Set up server with custom directories
|
|
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 {
|
|
// Simulate static files created by build
|
|
fs.mkdirSync(path.join(tmpDir, "posts/2026-09-22-secret-memo"), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, "posts/2026-09-20-public-post"), { recursive: true });
|
|
|
|
const secretHtml = `<!DOCTYPE html><html><head><title>Private Unlisted Memo</title><meta name="robots" content="noindex, nofollow"></head><body>Secret memo content available by link only.</body></html>`;
|
|
const publicHtml = `<!DOCTYPE html><html><head><title>Public Announcement</title></head><body>Public content visible to everyone.</body></html>`;
|
|
const indexHtml = `<!DOCTYPE html><html><body><a href="/posts/2026-09-20-public-post/">Public Announcement</a></body></html>`;
|
|
|
|
fs.writeFileSync(path.join(tmpDir, "posts/2026-09-22-secret-memo/index.html"), secretHtml);
|
|
fs.writeFileSync(path.join(tmpDir, "posts/2026-09-20-public-post/index.html"), publicHtml);
|
|
fs.writeFileSync(path.join(tmpDir, "index.html"), indexHtml);
|
|
|
|
// Verify direct access to hidden post succeeds (HTTP 200 OK)
|
|
const resSecret = await fetch(`http://127.0.0.1:${port}/posts/2026-09-22-secret-memo/`);
|
|
assert.strictEqual(resSecret.status, 200, "Direct link to hidden post must return 200 OK");
|
|
const secretBody = await resSecret.text();
|
|
assert.match(secretBody, /Secret memo content available by link only/, "Hidden post body is accessible");
|
|
assert.match(secretBody, /noindex,\s*nofollow/, "Hidden post includes noindex meta tag");
|
|
|
|
// Verify public index does NOT contain link or title to hidden post
|
|
const resIndex = await fetch(`http://127.0.0.1:${port}/`);
|
|
assert.strictEqual(resIndex.status, 200);
|
|
const indexBody = await resIndex.text();
|
|
assert.match(indexBody, /Public Announcement/, "Public post is listed on index");
|
|
assert.doesNotMatch(indexBody, /Private Unlisted Memo/, "Hidden post MUST NOT appear on index");
|
|
assert.doesNotMatch(indexBody, /secret-memo/, "Hidden post link MUST NOT appear on index");
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Hidden Posts: Obsidian API ingestion correctly writes hidden frontmatter", 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 {
|
|
const res = 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: "unlisted-post",
|
|
content: `---
|
|
title: "Direct Share Only"
|
|
created: 2026-09-23
|
|
hidden: true
|
|
tags: ["#private"]
|
|
---
|
|
Secret note content.`
|
|
})
|
|
});
|
|
|
|
assert.strictEqual(res.status, 200);
|
|
const json = await res.json();
|
|
assert.strictEqual(json.status, "published");
|
|
assert.strictEqual(json.path, "/posts/unlisted-post/");
|
|
|
|
const writtenFile = path.join(tmpPosts, "unlisted-post.md");
|
|
assert.ok(fs.existsSync(writtenFile), "Post file must exist");
|
|
const writtenContent = fs.readFileSync(writtenFile, "utf-8");
|
|
assert.match(writtenContent, /hidden:\s*true/);
|
|
assert.match(writtenContent, /layout:\s*layouts\/post\.njk/);
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|