mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
32cf13a5cb
services/admin is a client of the content service's HTTP API, served on the tailnet only. It renders on the server with plain forms and no client scripts, and holds the admin token so the browser never sees it. - Review queue: reasons, line diff against the live version, accept, reject, and reject with the live version put back into Obsidian - Notes and drafts: search, preview, fields, backlinks, publish toggle - History with compare and roll back - Quick capture for a status or a Library item (optionally both) - Store: orders open or closed per category, open slots and stock - Overview: counts, last scan and error, scan now, sync job health - Same-origin check on every post, strict CSP, sanitized previews The content service gains /api/status, sync run reports (/api/syncs) and lookup of a note by name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
108 lines
5.4 KiB
TypeScript
108 lines
5.4 KiB
TypeScript
import type { AddressInfo } from "node:net";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
import { parseTokens } from "../src/config.ts";
|
|
import { createHttpServer } from "../src/http.ts";
|
|
import { book, post, rig, type Rig } from "./helpers.ts";
|
|
|
|
const T = { reader: "r".repeat(24), agent: "a".repeat(24), editor: "e".repeat(24), admin: "d".repeat(24) };
|
|
let r: Rig;
|
|
let base: string;
|
|
let server: ReturnType<typeof createHttpServer>;
|
|
|
|
beforeAll(async () => {
|
|
r = rig();
|
|
r.put({ type: "status-kinds", title: "Status kinds", publish: true, kinds: [{ label: "Reading", icon: "book" }] });
|
|
r.put(book("Mistborn"), "About [[Elantris]].");
|
|
r.put(book("Elantris"));
|
|
r.put(post("Secret draft", { publish: false }));
|
|
r.service.scan();
|
|
server = createHttpServer(r.service, { tokens: parseTokens(Object.entries(T).map(([k, v]) => `${k}:${v}`).join(",")) });
|
|
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
|
|
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
});
|
|
afterAll(() => {
|
|
server.close();
|
|
r.done();
|
|
});
|
|
|
|
const get = (path: string, token?: string) =>
|
|
fetch(base + path, { headers: token ? { authorization: `Bearer ${token}` } : {} });
|
|
const post_ = (path: string, token: string, body: unknown) =>
|
|
fetch(base + path, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
|
|
describe("HTTP API", () => {
|
|
it("needs a token for everything but health", async () => {
|
|
expect((await get("/health")).status).toBe(200);
|
|
expect((await get("/api/notes")).status).toBe(401);
|
|
expect((await get("/api/notes", "wrong".repeat(6))).status).toBe(401);
|
|
});
|
|
|
|
it("shows readers published notes only", async () => {
|
|
const reader = (await (await get("/api/notes?type=post", T.reader)).json()) as unknown[];
|
|
const editor = (await (await get("/api/notes?type=post", T.editor)).json()) as unknown[];
|
|
expect(reader).toHaveLength(0);
|
|
expect(editor).toHaveLength(1);
|
|
expect((await get("/api/note?path=Posts/Secret%20draft.md", T.reader)).status).toBe(404);
|
|
});
|
|
|
|
it("finds a note by slug with its backlinks", async () => {
|
|
const n = (await (await get("/api/note?type=library-item&slug=elantris", T.reader)).json()) as { title: string; backlinks: { title: string }[] };
|
|
expect(n.title).toBe("Elantris");
|
|
expect(n.backlinks.map((b) => b.title)).toEqual(["Mistborn"]);
|
|
});
|
|
|
|
it("keeps readers from writing and agents from reviewing", async () => {
|
|
expect((await post_("/api/changes", T.reader, { op: "create", data: book("X") })).status).toBe(403);
|
|
expect((await post_("/api/held/1/accept", T.agent, {})).status).toBe(403);
|
|
});
|
|
|
|
it("applies a change and reports rejections plainly", async () => {
|
|
const ok = await post_("/api/changes", T.editor, { op: "create", data: book("Warbreaker") });
|
|
expect(ok.status).toBe(200);
|
|
const bad = await post_("/api/changes", T.editor, { op: "create", data: { type: "library-item", title: "Y" } });
|
|
expect(bad.status).toBe(422);
|
|
});
|
|
});
|
|
|
|
describe("sync reports", () => {
|
|
it("records a run and shows it in the status", async () => {
|
|
expect((await post_("/api/syncs", T.agent, { job: "personalreader", ok: true, counts: { updated: 3 } })).status).toBe(200);
|
|
expect((await post_("/api/syncs", T.agent, { job: "Bad Name", ok: true })).status).toBe(400);
|
|
const status = (await (await get("/api/status", T.admin)).json()) as { syncs: { job: string; lastOkAt: string }[] };
|
|
expect(status.syncs[0]).toMatchObject({ job: "personalreader" });
|
|
expect(status.syncs[0]!.lastOkAt).toBeTruthy();
|
|
expect((await get("/api/status", T.reader)).status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("MCP server", () => {
|
|
it("lets an agent search, post a status and draft a post, but not publish it", async () => {
|
|
const client = new Client({ name: "test", version: "1.0.0" });
|
|
await client.connect(
|
|
new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { authorization: `Bearer ${T.agent}` } } }),
|
|
);
|
|
const tools = (await client.listTools()).tools.map((t) => t.name).sort();
|
|
expect(tools).toEqual(["add_library_item", "draft_post", "get_note", "get_schema", "list_held", "search", "set_status", "update_fields"]);
|
|
|
|
const found = await client.callTool({ name: "search", arguments: { query: "mistborn" } });
|
|
expect(JSON.stringify(found.content)).toContain("Library/Books/Mistborn.md");
|
|
|
|
const status = await client.callTool({ name: "set_status", arguments: { kind: "reading", title: "Mistborn", subject: "Mistborn" } });
|
|
expect(status.isError).toBeFalsy();
|
|
const badKind = await client.callTool({ name: "set_status", arguments: { kind: "Juggling", title: "Balls" } });
|
|
expect(badKind.isError).toBe(true);
|
|
|
|
const draft = await client.callTool({
|
|
name: "draft_post",
|
|
arguments: { title: "Agent draft", topic: "Making", summary: "S.", body: "Text." },
|
|
});
|
|
expect(draft.isError).toBeFalsy();
|
|
const publish = await client.callTool({ name: "update_fields", arguments: { path: "Posts/Agent draft.md", fields: { publish: true } } });
|
|
expect(JSON.stringify(publish.content)).toContain("agent-publish");
|
|
expect(r.store.get("Posts/Agent draft.md")!.publish).toBe(false);
|
|
await client.close();
|
|
});
|
|
});
|