mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Add the admin page (phase 3)
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
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { parseTokens } from "../../content/src/config.ts";
|
||||
import { createHttpServer } from "../../content/src/http.ts";
|
||||
import { book, rig, type Rig } from "../../content/test/helpers.ts";
|
||||
import { ContentApi } from "../src/client.ts";
|
||||
import { createAdminServer } from "../src/server.ts";
|
||||
|
||||
const ADMIN = "d".repeat(24);
|
||||
let r: Rig;
|
||||
let content: Server;
|
||||
let admin: Server;
|
||||
let base: string;
|
||||
|
||||
const listen = async (s: Server) => {
|
||||
await new Promise<void>((ok) => s.listen(0, "127.0.0.1", ok));
|
||||
return `http://127.0.0.1:${(s.address() as AddressInfo).port}`;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
r = rig();
|
||||
r.put({ type: "status-kinds", title: "Status kinds", publish: true, kinds: [{ label: "Reading", icon: "book" }, { label: "Playing", icon: "dice" }] });
|
||||
r.put(
|
||||
{
|
||||
type: "store-settings",
|
||||
title: "Store settings",
|
||||
publish: true,
|
||||
categories: [
|
||||
{ name: "Tabletop", ordersOpen: true, turnaround: "2 weeks" },
|
||||
{ name: "Paper", ordersOpen: false },
|
||||
],
|
||||
},
|
||||
);
|
||||
r.put({
|
||||
type: "product-line",
|
||||
title: "Proxy cards",
|
||||
publish: true,
|
||||
category: "Tabletop",
|
||||
mode: "made to order",
|
||||
channel: "here",
|
||||
summary: "Cards.",
|
||||
slots: { open: 3, total: 5 },
|
||||
priceTiers: [{ band: "1-10", price: "$1" }],
|
||||
});
|
||||
r.put(book("Mistborn"), "Allomancy.");
|
||||
r.service.scan();
|
||||
content = createHttpServer(r.service, { tokens: parseTokens(`admin:${ADMIN}`) });
|
||||
const contentUrl = await listen(content);
|
||||
admin = createAdminServer(new ContentApi(contentUrl, ADMIN), { now: () => new Date("2026-10-11T12:00:00Z") });
|
||||
base = await listen(admin);
|
||||
});
|
||||
afterAll(() => {
|
||||
admin.close();
|
||||
content.close();
|
||||
r.done();
|
||||
});
|
||||
|
||||
const get = async (path: string) => {
|
||||
const res = await fetch(base + path, { redirect: "manual" });
|
||||
return { status: res.status, text: await res.text(), location: res.headers.get("location") };
|
||||
};
|
||||
const post = async (path: string, form: Record<string, string>, headers: Record<string, string> = {}) => {
|
||||
const res = await fetch(base + path, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", "sec-fetch-site": "same-origin", ...headers },
|
||||
body: new URLSearchParams(form).toString(),
|
||||
});
|
||||
return { status: res.status, location: res.headers.get("location") ?? "" };
|
||||
};
|
||||
|
||||
describe("admin page", () => {
|
||||
it("shows the overview", async () => {
|
||||
const page = await get("/");
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.text).toContain("live notes of 4");
|
||||
expect(page.text).toContain("No sync job has reported yet");
|
||||
});
|
||||
|
||||
it("refuses a form posted from another site", async () => {
|
||||
expect((await post("/scan", {}, { "sec-fetch-site": "cross-site" })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("captures a Library item with a status, written into the vault", async () => {
|
||||
const res = await post("/capture/library", { title: "Warbreaker", medium: "book", state: "now", publish: "1", alsoStatus: "1", kind: "Reading" });
|
||||
expect(res.location).toContain("msg=");
|
||||
expect(existsSync(join(r.site, "Library/Books/Warbreaker.md"))).toBe(true);
|
||||
const status = r.store.list({ type: "status" })[0]!;
|
||||
expect(status.data).toMatchObject({ kind: "Reading", subject: "[[Warbreaker]]", date: "2026-10-11" });
|
||||
expect(status.author).toBe("admin");
|
||||
});
|
||||
|
||||
it("sends a rejected capture back with the reasons", async () => {
|
||||
const res = await post("/capture/library", { title: "Warbreaker", medium: "book", state: "now" });
|
||||
expect(res.location).toMatch(/err=.*r=/);
|
||||
});
|
||||
|
||||
it("reviews a held change with a diff and accepts it", async () => {
|
||||
const o = r.service.apply({ op: "update", path: "Library/Books/Mistborn.md", fields: { verdict: "A heist." } }, "agent");
|
||||
expect(o.status).toBe("held");
|
||||
const id = o.status === "held" ? o.held.id : 0;
|
||||
const list = await get("/review");
|
||||
expect(list.text).toContain(`/review/${id}`);
|
||||
const one = await get(`/review/${id}`);
|
||||
expect(one.text).toContain('<div class="add">verdict: A heist.</div>');
|
||||
expect(one.text).toContain("agent-edit");
|
||||
const res = await post(`/review/${id}/accept`, {});
|
||||
expect(res.location).toContain("Accepted");
|
||||
expect(readFileSync(join(r.site, "Library/Books/Mistborn.md"), "utf8")).toContain("verdict: A heist.");
|
||||
});
|
||||
|
||||
it("previews a note, shows its history and rolls it back", async () => {
|
||||
const page = await get("/note?path=Library%2FBooks%2FMistborn.md");
|
||||
expect(page.text).toContain("<p>Allomancy.</p>");
|
||||
expect(page.text).toContain("Compare and roll back");
|
||||
const rev = await get("/note/revision?path=Library%2FBooks%2FMistborn.md&rev=1");
|
||||
expect(rev.text).toContain('<div class="remove">verdict: A heist.</div>');
|
||||
await post("/note/rollback", { path: "Library/Books/Mistborn.md", rev: "1" });
|
||||
expect(r.store.get("Library/Books/Mistborn.md")!.data.verdict).toBeUndefined();
|
||||
});
|
||||
|
||||
it("publishes a draft", async () => {
|
||||
r.put(book("Elantris", { publish: false }));
|
||||
r.service.scan();
|
||||
expect((await get("/drafts")).text).toContain("Elantris");
|
||||
await post("/note/publish", { path: "Library/Books/Elantris.md", publish: "1" });
|
||||
expect(r.store.get("Library/Books/Elantris.md")!.publish).toBe(true);
|
||||
});
|
||||
|
||||
it("opens and closes orders and sets slots", async () => {
|
||||
expect((await get("/store")).text).toContain("Proxy cards");
|
||||
await post("/store/category", { name: "Paper", ordersOpen: "1", turnaround: "3 weeks" });
|
||||
await post("/store/category", { name: "Tabletop" });
|
||||
const cats = r.store.get("Store/_settings.md")!.data.categories;
|
||||
expect(cats).toEqual([
|
||||
{ name: "Tabletop", ordersOpen: false },
|
||||
{ name: "Paper", ordersOpen: true, turnaround: "3 weeks" },
|
||||
]);
|
||||
await post("/store/stock", { path: "Store/Lines/Proxy cards.md", open: "1" });
|
||||
expect(r.store.get("Store/Lines/Proxy cards.md")!.data.slots).toEqual({ open: 1, total: 5 });
|
||||
});
|
||||
|
||||
it("shows sync jobs once they report", async () => {
|
||||
r.store.reportSync({ job: "personalreader", at: "2026-10-11T11:00:00Z", ok: false, message: "Suwayomi did not answer", counts: { updated: 0 } });
|
||||
const page = await get("/");
|
||||
expect(page.text).toContain("personalreader");
|
||||
expect(page.text).toContain("Suwayomi did not answer");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { diffLines } from "../src/diff.ts";
|
||||
import { html, raw } from "../src/html.ts";
|
||||
import { renderMarkdown } from "../src/markdown.ts";
|
||||
|
||||
const md = (s: string) => renderMarkdown(s, { noteHref: (n) => `/go?name=${encodeURIComponent(n)}`, imageSrc: (f) => `/attachments/${f}` });
|
||||
|
||||
describe("diff", () => {
|
||||
it("marks added and removed lines and folds the rest", () => {
|
||||
const before = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"].join("\n");
|
||||
const after = ["a", "b", "c", "d", "e", "F", "g", "h", "i", "j"].join("\n");
|
||||
const d = diffLines(before, after, 1);
|
||||
expect(d).toEqual([
|
||||
{ kind: "skip", count: 4 },
|
||||
{ kind: "same", text: "e" },
|
||||
{ kind: "remove", text: "f" },
|
||||
{ kind: "add", text: "F" },
|
||||
{ kind: "same", text: "g" },
|
||||
{ kind: "skip", count: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows a new note as all added", () => {
|
||||
expect(diffLines("", "x\ny")).toEqual([
|
||||
{ kind: "add", text: "x" },
|
||||
{ kind: "add", text: "y" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("html", () => {
|
||||
it("escapes values but not nested html", () => {
|
||||
expect(html`<p>${"<b>"}${html`<i>${"&"}</i>`}${raw("<hr>")}</p>`.toString()).toBe("<p><b><i>&</i><hr></p>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("draft previews", () => {
|
||||
it("never lets a draft run script", () => {
|
||||
const out = md('<script>alert(1)</script>\n\n[x](javascript:alert(1)) <img src=x onerror=alert(1)>');
|
||||
expect(out).not.toMatch(/<script|<img src=x|javascript:/i);
|
||||
expect(out).toContain("<script>");
|
||||
});
|
||||
|
||||
it("turns wikilinks and embeds into links and images", () => {
|
||||
const out = md("See [[Mistborn|the book]] and ![[cover.png]].");
|
||||
expect(out).toContain('<a href="/go?name=Mistborn"');
|
||||
expect(out).toContain("the book</a>");
|
||||
expect(out).toContain('<img src="/attachments/cover.png"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user