Files
obsidian-foundry-sync/tests/e1b2-baseline.test.ts
Kaysser Kayyali 8406a0a52a feat(E1b.3): TOCTOU post-push re-verify + conflict rows + dashboard badge
Closes the TOCTOU window E1b.1/E1b.2 left open: Foundry can be edited by
another client between the pre-push /get and the PUT response.

- src/server.ts runPushBody: after a successful push, issue a SECOND relay.getEntry
  (the one extra /get the epic permits), compare post-push ccHash to
  ccHash(pushedEntry) (what we just wrote). Match → baseline per E1b.2. Mismatch →
  TOCTOU conflict: record a ConflictRow {uuid,name,obsidianHash,foundryPreHash,
  foundryPostHash,time,relPath} in a bounded (50, newest-first) in-memory list,
  log "skipped" "TOCTOU conflict — Foundry edited during push, NOT baselined; use
  Sync / Re-pull", leave baselines + in-memory ccHash untouched so the next save
  re-surfaces the divergence. Re-verify /get failure → error log, no baseline,
  push stays live (no rollback). A successful push clears any prior conflict for
  that uuid. (Documented deviation: the AC said compare to the "pre-PUT captured
  entry", but our own push changes Foundry so that would always differ — the
  correct comparison is post-push /get vs ccHash(pushedEntry), the expected state.)
  Gated by foundryGuardEnabled (flag off → no TOCTOU check, baseline directly).
- src/server.ts: ConflictRow type exported; status() adds conflictCount + the
  conflicts list; GET /api/autosync/conflicts endpoint (registered unguarded —
  E7's auth middleware not landed yet; E7 will gate it).
- src/dashboard.html: conflict badge on the Auto-sync button (⚠ N conflicts) +
  a loud note when non-zero, driven by the existing /api/autosync poll.
- tests: new e1b3-toctou.test.ts (5 tests — clean, TOCTOU mismatch, re-verify
  failure, conflict cleared on next success, status exposes conflicts). Updated
  e1b1/e1b2 mocks to behave like Foundry (/get returns current state, /update
  applies the diff) so the re-/get returns the pushed state (no false conflict);
  e1b1 clean test now expects 2 /gets (push + re-verify).

tsc clean; 129 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 23:49:17 +00:00

200 lines
9.4 KiB
TypeScript

// E1b.2c — dual re-baseline + self-write-suppression tests.
//
// Exercises the REAL pushNote (with the guard + pushedEntry) against a MOCKED
// relay (globalThis.fetch) that behaves like Foundry: /get returns the current
// state, /update applies the diff (so the E1b.3 post-push re-/get returns the
// pushed state → no false TOCTOU conflict). Covers:
// 1. a clean push re-baselines BOTH foundry.contentHash (body) AND foundry.ccHash
// (ccHash(pushedEntry) — the post-push state, no extra /get beyond E1b.3's).
// 2. the controller's own baseline write is dropped by self-write suppression
// (same relPath + mtime → no debounce timer armed, "self-write (baseline)" log).
// 3. a user edit (different mtime) is NOT suppressed (debounce timer armed).
// 4. after the TTL expires, a same-mtime event is processed again (timer armed).
//
// Live SM-2 verification stays gated on the operator's headless session.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, writeFile, mkdir, rm, readFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AutoSyncController } from "../src/server.js";
import type { State, ServerConfig } from "../src/server.js";
import { ccHash } from "../src/cchash.js";
import { contentHash } from "../src/normalize.js";
import { obsidianToFoundryJsonLive } from "../src/toFoundry.js";
import { splitFrontmatter, readFoundryBlock } from "../src/frontmatter.js";
import type { JournalEntry } from "../src/types.js";
import type { NameResolver } from "../src/resolver.js";
const UUID = "JournalEntry.abc1";
const REL = "Roland.md";
const EMPTY_RESOLVER: NameResolver = { nameOf: () => undefined, uuidOf: () => undefined };
function liveEntry(description: string, name = "Roland"): JournalEntry {
return { name, _id: "abc1", folder: "Folder.test", flags: { "campaign-codex": { type: "npc", data: { description, notes: "" } } } };
}
/** A refined note: STALE contentHash (so the body gate passes) + a ccHash baseline. */
function seededNote(ccHashBaseline: string, body: string): string {
return [
"---", "type: npc", "foundry:",
` cc_uuid: ${UUID}`, " cc_type: npc", " folder_path: NPCs",
` contentHash: ${"0".repeat(64)}`,
` ccHash: ${ccHashBaseline}`,
" syncedAt: 2026-06-22T00:00:00.000Z",
"---", body, "",
].join("\n");
}
let dir: string;
let state: State;
let putCalls: number;
// The mock relay's current Foundry state. /get returns it; /update applies the
// diff so the E1b.3 post-push /get returns the pushed state (ccHash matches
// ccHash(pushedEntry) → no false conflict). Tests capture `prePush` before
// calling process (since /update mutates currentState).
let currentState: JournalEntry;
const realFetch = globalThis.fetch;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "e1b2-"));
const refinedDir = join(dir, "refined");
const outDir = join(dir, "out");
await mkdir(refinedDir, { recursive: true });
putCalls = 0;
currentState = liveEntry("<p>Original body.</p>");
const cfg: ServerConfig = {
journal: "", refinedDir, ccDir: "", outDir, mode: "apply", port: 0, host: "",
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
};
state = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
globalThis.fetch = vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url); const method = init?.method ?? "GET";
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
const resp = (o: unknown, ok = true, status = 200) => ({ ok, status, text: async () => (typeof o === "string" ? o : JSON.stringify(o)) });
if (method === "GET" && u.includes("/get?")) return resp({ data: currentState });
if (method === "GET" && u.includes("/search")) return resp({ results: [] });
if (method === "PUT" && u.includes("/update?")) {
putCalls++;
const diff = body?.data ?? {};
currentState = {
...currentState,
name: diff.name ?? currentState.name,
flags: { ...currentState.flags, "campaign-codex": diff["flags.campaign-codex"] ?? currentState.flags?.["campaign-codex"] },
};
return resp({ entity: [currentState] });
}
return resp({ error: "not found" }, false, 404);
}) as unknown as typeof fetch;
});
afterEach(async () => {
globalThis.fetch = realFetch;
await rm(dir, { recursive: true, force: true });
});
async function writeNote(ccHashBaseline: string, body: string): Promise<void> {
await writeFile(join(state.cfg.refinedDir, REL), seededNote(ccHashBaseline, body), "utf8");
}
/** The expected pushed entry for a given body + pre-push base entry. */
function expectedPushedEntry(body: string, baseEntry: JournalEntry): JournalEntry {
return obsidianToFoundryJsonLive(body, "Roland", baseEntry, EMPTY_RESOLVER);
}
describe("E1b.2a dual re-baseline (contentHash + ccHash) on push success", () => {
it("a clean push re-baselines BOTH foundry.contentHash and foundry.ccHash", async () => {
const body = "The gunslinger drew his revolver.\n";
const prePush = currentState;
// Baseline = ccHash(prePush) so the E1b.1 guard passes (no Foundry drift).
await writeNote(ccHash(prePush), body);
const controller = new AutoSyncController(state);
await (controller as any).process(REL);
expect(putCalls).toBe(1); // the push fired
const md = await readFile(join(state.cfg.refinedDir, REL), "utf8");
const { fm, body: bodyAfter } = splitFrontmatter(md);
const fb = readFoundryBlock(fm);
// contentHash re-baselined to the body hash (idempotency).
expect(fb?.contentHash).toBe(contentHash(bodyAfter));
expect(fb?.contentHash).toBe(contentHash(body));
// ccHash re-baselined to ccHash(pushedEntry) — the post-push Foundry state.
expect(fb?.ccHash).toBe(ccHash(expectedPushedEntry(body, prePush)));
expect(controller.events.some((e) => e.status === "pushed" && e.message.includes("baselined (content+cc)"))).toBe(true);
});
it("a drift-aborted push does NOT re-baseline (baselines left untouched)", async () => {
const body = "The gunslinger drew his revolver.\n";
const baselineBefore = ccHash(currentState);
await writeNote(baselineBefore, body);
currentState = liveEntry("<p>Foundry-edited body.</p>"); // drift → guard aborts
const controller = new AutoSyncController(state);
await (controller as any).process(REL);
expect(putCalls).toBe(0); // no PUT
const md = await readFile(join(state.cfg.refinedDir, REL), "utf8");
const fb = readFoundryBlock(splitFrontmatter(md).fm);
// Baselines untouched — the note still carries the pre-push baseline.
expect(fb?.ccHash).toBe(baselineBefore);
expect(fb?.contentHash).toBe("0".repeat(64)); // still the stale baseline
});
});
describe("E1b.2b self-write suppression (onChange recognizes the controller's own baseline write)", () => {
it("a successful push records the baseline mtime; the same-mtime change is dropped (no re-push)", async () => {
const body = "The gunslinger drew his revolver.\n";
await writeNote(ccHash(currentState), body);
const controller = new AutoSyncController(state);
await (controller as any).process(REL);
expect(putCalls).toBe(1);
// Simulate the watcher firing on the controller's own baseline write (file
// unchanged since the baseline → same mtime as recorded).
(controller as any).onChange("change", "Roland.md", "");
// No debounce timer armed for the self-write…
expect((controller as any).timers.has(REL)).toBe(false);
// …and a self-write skip was logged.
expect(controller.events.some((e) => e.message.includes("self-write (baseline)"))).toBe(true);
// No second push.
expect(putCalls).toBe(1);
});
it("a user edit (different mtime) is NOT suppressed — debounce timer arms", async () => {
const body = "The gunslinger drew his revolver.\n";
await writeNote(ccHash(currentState), body);
const controller = new AutoSyncController(state);
await (controller as any).process(REL);
expect(putCalls).toBe(1);
// User edits the note (new content → new mtime).
await writeFile(join(state.cfg.refinedDir, REL), seededNote(ccHash(currentState), "The gunslinger fled the desert.\n"), "utf8");
(controller as any).onChange("change", "Roland.md", "");
// The debounce timer IS armed (the user edit was not suppressed)…
expect((controller as any).timers.has(REL)).toBe(true);
// …and no self-write skip was logged for this change.
expect(controller.events.some((e) => e.message.includes("self-write (baseline)"))).toBe(false);
});
it("after the suppression TTL expires, a same-mtime event is processed again", async () => {
const body = "The gunslinger drew his revolver.\n";
await writeNote(ccHash(currentState), body);
const controller = new AutoSyncController(state);
// Shrink the TTL so the test doesn't wait 2s.
(controller as any).baselineSuppressMs = 15;
await (controller as any).process(REL);
expect(putCalls).toBe(1);
// Wait past the TTL, then fire the same-mtime change.
await new Promise<void>((r) => setTimeout(r, 40));
(controller as any).onChange("change", "Roland.md", "");
// TTL expired → not suppressed → debounce timer armed.
expect((controller as any).timers.has(REL)).toBe(true);
expect(controller.events.some((e) => e.message.includes("self-write (baseline)"))).toBe(false);
});
});