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>
200 lines
9.4 KiB
TypeScript
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);
|
|
});
|
|
}); |