Ctrl K
ComunidadGuías…

tests/solidity.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Server solidity: the five ways this process used to lie, lose or die.
//
// Every case below is a measured failure from the v1.8 audit, and each one is
// pinned at the seam that fixed it:
//
//   A1  /api/posts was O(published × total) — `isTemplateNote()` re-walked the
//       whole index once per published post. The memo has to INVALIDATE, or the
//       cure is worse than the disease: a templates folder that appears or
//       disappears must be seen.
//   A2  the minisearch auto-vacuum raced index mutations and killed the
//       process. Mutations now go through one chain; a save storm must survive.
//   A3  a bare `catch` around the write precondition's `stat` turned "I could
//       not look" into "there is nothing there", so a save that could not be
//       checked was performed anyway; and raw errnos reached clients as 500s.
//   A4  a transient read error evicted a note from the index until restart.
//   A5  the SSE stream narrated a `git pull` one file at a time.
//   A6  boot seeded `vault-seed/` into a directory the reader had made.

import assert from "node:assert/strict";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import {
  detectTemplatesFolder,
  indexFile,
  indexStats,
  initIndexer,
  isTemplateNote,
  posts as postsRaw,
  search as searchRaw,
  whenIndexed,
} from "../server/indexer.ts";
import { hasMarkdown, seedAvailable, seedVault } from "../server/seed.ts";
import { initSite } from "../server/site.ts";
import {
  VaultError,
  emitEvent,
  initVault,
  onEventCoalesced,
  writeFailure,
  writeNote,
} from "../server/vault.ts";
import type { VaultEvent } from "../shared/types.ts";
import { makeDir, makeVault, note, removeVault } from "./helpers/vault.ts";

const posts = () => postsRaw(false, null);
const search = (q: string) => searchRaw(q, false, null);

const data = makeDir();
const root = makeVault({
  "Alpha.md": note({ publish: "true" }, "# Alpha\n\nA paragraph long enough to be a real excerpt for this note.\n"),
  "Beta.md": note({ publish: "true" }, "# Beta\n\nAnother paragraph, also long enough to count as prose here.\n"),
  "guarded/Held.md": note({ publish: "true" }, "# Held\n\nThe unmistakable word chartreuse lives in this note's prose.\n"),
  "precondition/Note.md": "# original\n",
});

before(async () => {
  initSite({ ASTROLABE_DATA: data });
  initVault(root);
  await initIndexer();
});

after(() => {
  for (const dir of ["guarded", "precondition"]) {
    try {
      chmodSync(path.join(root, dir), 0o755);
    } catch {
      /* already writable */
    }
  }
  removeVault(root);
  removeVault(data);
});

const asRoot = process.getuid?.() === 0;

// ─────────────────────────────────────────────────────── A1: templates memo

describe("templates folder detection", () => {
  it("answers nothing for a vault that has no templates folder", () => {
    assert.equal(detectTemplatesFolder(), null);
    assert.equal(isTemplateNote("Alpha.md"), false);
  });

  it("SEES a templates folder that appears — the memo invalidates on index writes", async () => {
    mkdirSync(path.join(root, "Templates"), { recursive: true });
    writeFileSync(path.join(root, "Templates/Daily.md"), note({ publish: "true" }, "# {{date}}\n"));
    await indexFile("Templates/Daily.md");
    assert.equal(detectTemplatesFolder(), "Templates");
    assert.equal(isTemplateNote("Templates/Daily.md"), true);
  });

  it("keeps a stencil out of the post list even when it says publish: true", () => {
    const listed = posts().map((p) => p.path);
    assert.ok(listed.includes("Alpha.md"));
    assert.ok(!listed.includes("Templates/Daily.md"), "a template reached the blog's post list");
  });

  it("SEES it go away again", async () => {
    const abs = path.join(root, "Templates/Daily.md");
    writeFileSync(abs, "");
    await import("node:fs/promises").then((fs) => fs.rm(abs));
    await indexFile("Templates/Daily.md"); // absent → removed from the index
    assert.equal(detectTemplatesFolder(), null);
  });
});

// ───────────────────────────────────────── A2: one chain, no vacuum races

describe("index mutations are serialized", () => {
  it("survives a save storm from two writers without losing the index", async () => {
    // The audit's shape, shrunk to a test: two clients alternating writes to
    // two notes, fast enough that a batched vacuum would overlap a mutation.
    // 400 writes was enough to kill the process 5/5 before autoVacuum went off.
    const files = ["Alpha.md", "Beta.md"];
    for (let i = 0; i < 400; i++) {
      const file = files[i % 2];
      const body = `# ${file}\n\nRevision ${i} of a paragraph long enough to count as prose in this vault.\n`;
      writeFileSync(path.join(root, file), note({ publish: "true" }, body));
      // Not awaited every time: the point is to have several in flight at once,
      // which is exactly what two browsers autosaving do.
      void indexFile(file);
    }
    await whenIndexed();
    // The index is still coherent: both notes searchable, one record each.
    const hits = search("Revision");
    assert.equal(hits.filter((h) => h.path === "Alpha.md").length, 1);
    assert.equal(hits.filter((h) => h.path === "Beta.md").length, 1);
    assert.equal(posts().filter((p) => p.path === "Alpha.md").length, 1);
    // 400 discards left plenty of dirt for the deferred vacuum to find.
    assert.ok(indexStats().dirt > 20, `dirt ${indexStats().dirt} — nothing to vacuum?`);
  });

  it("vacuums on its own schedule once the storm stops", async () => {
    // The vacuum runs on the chain, ~2s after the last mutation. A vacuum
    // nobody can observe is a vacuum that quietly stopped happening — and the
    // reason it is deferred at all is that minisearch's own (auto, batched,
    // between-ticks) one raced the saves above and killed the process.
    for (let i = 0; i < 40; i++) {
      if (indexStats().dirt === 0) break;
      await new Promise((resolve) => setTimeout(resolve, 200));
      await whenIndexed();
    }
    assert.equal(indexStats().dirt, 0, "the deferred vacuum never ran");
    // …and the index still answers correctly afterwards.
    assert.equal(search("Revision").filter((h) => h.path === "Alpha.md").length, 1);
  });
});

// ──────────────────────────────────────────── A3: the write path's errnos

describe("write failures say what happened", () => {
  it("maps the errnos a reader can act on, and passes anything else through", () => {
    const full = writeFailure(Object.assign(new Error("x"), { code: "ENOSPC" }), "Note.md");
    assert.ok(full instanceof VaultError);
    assert.equal(full.code, "diskFull");
    assert.equal(full.status, 507);
    const ro = writeFailure(Object.assign(new Error("x"), { code: "EROFS" }), "Note.md");
    assert.equal((ro as VaultError).code, "readOnly");
    // A bug in our own code must NOT come back dressed as a disk problem.
    const bug = new TypeError("undefined is not a function");
    assert.equal(writeFailure(bug, "Note.md"), bug);
  });

  it("REFUSES a precondition it could not check, instead of writing anyway", async (t) => {
    if (asRoot) {
      t.skip("running as root: permission bits do not apply");
      return;
    }
    const dir = path.join(root, "precondition");
    const abs = path.join(dir, "Note.md");
    const before = readFileSync(abs, "utf8");
    chmodSync(dir, 0o600); // readable, not traversable: stat inside → EACCES
    try {
      // The bug: this `stat` failing used to be swallowed, the precondition
      // skipped, and the write performed — the one thing the precondition
      // exists to prevent.
      await assert.rejects(
        () => writeNote("precondition/Note.md", "# clobbered\n", 1),
        (err: unknown) => err instanceof VaultError && err.status !== 409,
      );
    } finally {
      chmodSync(dir, 0o755);
    }
    assert.equal(readFileSync(abs, "utf8"), before, "the unguarded write went through");
  });
});

// ────────────────────────────────────── A4: a read that failed is not a delete

describe("a transient read error does not evict a note", () => {
  it("keeps the stale record when the file cannot be read", async (t) => {
    if (asRoot) {
      t.skip("running as root: permission bits do not apply");
      return;
    }
    assert.equal(search("chartreuse").length, 1, "fixture note is not indexed");
    const dir = path.join(root, "guarded");
    chmodSync(dir, 0o600); // EACCES on every read under it
    try {
      await indexFile("guarded/Held.md");
      // The old behaviour: gone from search, gone from the graph, gone from
      // the post list, until the process restarted — and nothing said so.
      assert.equal(search("chartreuse").length, 1, "an EACCES evicted the note");
      assert.ok(posts().some((p) => p.path === "guarded/Held.md"));
    } finally {
      chmodSync(dir, 0o755);
    }
  });

  it("still removes a note that is genuinely gone", async () => {
    const abs = path.join(root, "Beta.md");
    const kept = readFileSync(abs, "utf8");
    await import("node:fs/promises").then((fs) => fs.rm(abs));
    await indexFile("Beta.md");
    assert.equal(posts().some((p) => p.path === "Beta.md"), false);
    writeFileSync(abs, kept);
    await indexFile("Beta.md");
  });
});

// ──────────────────────────────────────────────── A5: the aggregate coalescer

describe("the SSE coalescer", () => {
  it("narrates a few events and summarizes a storm", async () => {
    const heard: VaultEvent[] = [];
    const off = onEventCoalesced((ev) => heard.push(ev));
    try {
      // A handful: every one is named, because a client can follow that.
      for (let i = 0; i < 5; i++) emitEvent({ kind: "changed", path: `Small ${i}.md` });
      assert.equal(heard.length, 5);
      assert.ok(heard.every((e) => e.kind === "changed"));

      // A `git pull`: 1,000 files. The client is told once.
      heard.length = 0;
      for (let i = 0; i < 1000; i++) emitEvent({ kind: "changed", path: `Storm ${i}.md` });
      assert.ok(heard.length < 40, `narrated ${heard.length} of a 1000-file storm`);
      assert.ok(!heard.some((e) => e.kind === "bulk"), "the bulk frame went out mid-storm");
      await new Promise((resolve) => setTimeout(resolve, 500));
      const bulk = heard.filter((e) => e.kind === "bulk");
      assert.equal(bulk.length, 1, "exactly one bulk frame settles the storm");
      assert.equal(bulk[0].path, "");
    } finally {
      off();
    }
  });
});

// ──────────────────────────────────────────────────── A6: seeding on a click

describe("the starter vault is offered, not imposed", () => {
  it("is available for an empty directory the reader made", () => {
    const empty = makeDir();
    try {
      assert.equal(hasMarkdown(empty), false);
      assert.equal(seedAvailable(empty), true);
      const guide = seedVault(empty);
      assert.equal(guide, "Welcome.md");
      assert.ok(existsSync(path.join(empty, "Welcome.md")));
      assert.equal(hasMarkdown(empty), true);
    } finally {
      removeVault(empty);
    }
  });

  it("REFUSES a vault that already holds notes — twice is not an accident", () => {
    const mine = makeVault({ "Private/Journal.md": "# mine\n" });
    try {
      assert.equal(seedAvailable(mine), false);
      assert.equal(seedVault(mine), null);
      assert.ok(!existsSync(path.join(mine, "Welcome.md")), "the seed landed on the reader's vault");
    } finally {
      removeVault(mine);
    }
  });
});