Files
hyperguild/ingestion/internal/notes/edit_test.go
T
mathias 055af368cd
CI / Lint / Test / Vet (push) Successful in 29s
CI / Mirror to GitHub (push) Skipped
feat(mcp): brain_read + brain_edit, contained to the sync source (brain#4) (#96)
brain_read returns a note verbatim; brain_edit is str_replace with an
exactly-once match, operating on the tree brain-sync commits from.

An adversarial design review before implementation changed three things:

1. Governance files are not in the model-facing tool. Brain content is
   ingested from the outside world and reaches a model's context, and that
   model would hold the editor — one poisoned page could rewrite the
   CLAUDE.md a .dispatch-allow agent later loads as instructions. And
   brain-sync merges with --strategy-option=ours, so the live tree beats a
   human's corrective push on the conflicting lines. CLAUDE.md now goes
   through brain_edit_root: de-surfaced from tools/list and gated on the
   local static principal. The scopes are disjoint — the privileged door
   has the smaller reach.

2. Reads needed the same allowlist, and brain_get already lacked it: it
   returned verbatim session transcripts under archive/ by path. A test
   proved the exposure live before the fix.

3. Canonicalise before allowlisting — knowledge/../.git/hooks/post-commit
   would otherwise reach a hook that brain-sync executes in a pod holding
   the Gitea push credentials.

Plus overlapping match counting, UTF-8 validation both ways, refusal while
git is mid-operation, flock with the read inside the lock, atomic
same-dir rename, and reindex only when frontmatter is touched.

Refs brain#4.

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-08-18 18:14:05 +00:00

257 lines
7.6 KiB
Go

package notes
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func seed(t *testing.T, dir, rel, content string) string {
t.Helper()
abs := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return abs
}
func read(t *testing.T, abs string) string {
t.Helper()
b, err := os.ReadFile(abs)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestRead_ReturnsVerbatimBytes(t *testing.T) {
dir := newBrain(t)
const body = "---\ntitle: 'x'\n---\n\nbody with double spaces\r\nand a CRLF line\nno trailing newline"
seed(t, dir, "knowledge/verbatim.md", body)
got, err := Read(dir, "knowledge/verbatim.md")
if err != nil {
t.Fatalf("Read: %v", err)
}
if string(got) != body {
t.Errorf("Read returned %q, want the file byte-for-byte", got)
}
}
func TestRead_RefusesNonUTF8RatherThanCorruptingIt(t *testing.T) {
// The response is marshalled into a JSON string, and encoding/json
// replaces invalid UTF-8 with U+FFFD. Returning it would mean every
// old_str the caller derives from that region can never match, and
// pasting it back would write the corruption to disk permanently.
dir := newBrain(t)
seed(t, dir, "knowledge/bad.md", "ok \xff\xfe broken\n")
if _, err := Read(dir, "knowledge/bad.md"); err == nil {
t.Fatal("Read accepted invalid UTF-8; it must refuse rather than silently substitute")
}
}
func TestEdit_ReplacesExactlyOnceAndLeavesEveryOtherByteAlone(t *testing.T) {
dir := newBrain(t)
const body = "---\ntitle: 'x'\n---\n\nalpha\nbeta\ngamma\n"
abs := seed(t, dir, "knowledge/n.md", body)
res, err := Edit(dir, "knowledge/n.md", "beta", "BETA", ScopeNotes)
if err != nil {
t.Fatalf("Edit: %v", err)
}
want := strings.Replace(body, "beta", "BETA", 1)
if got := read(t, abs); got != want {
t.Errorf("file = %q, want %q", got, want)
}
if res.ContentHash == "" {
t.Error("result should carry a content hash for read-after-write confirmation")
}
}
func TestEdit_RefusesAmbiguousAndAbsentMatchesWithoutWriting(t *testing.T) {
dir := newBrain(t)
const body = "alpha\nalpha\n"
abs := seed(t, dir, "knowledge/n.md", body)
cases := []struct{ name, old, want string }{
{"two matches", "alpha", "2"},
{"no match", "delta", "0"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := Edit(dir, "knowledge/n.md", tc.old, "x", ScopeNotes)
if err == nil {
t.Fatal("want a loud refusal")
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("error must state the match count, got %v", err)
}
if got := read(t, abs); got != body {
t.Errorf("file changed on a refused edit: %q", got)
}
})
}
}
func TestEdit_CountsOverlappingMatches(t *testing.T) {
// bytes.Count counts NON-overlapping occurrences: "aa" in "aaa" is 1, so
// a Count-based uniqueness check calls an ambiguous edit unique.
dir := newBrain(t)
seed(t, dir, "knowledge/n.md", "aaa\n")
if _, err := Edit(dir, "knowledge/n.md", "aa", "b", ScopeNotes); err == nil {
t.Fatal("overlapping matches must be reported as ambiguous")
}
}
func TestEdit_RejectsDegenerateArguments(t *testing.T) {
dir := newBrain(t)
seed(t, dir, "knowledge/n.md", "alpha\n")
if _, err := Edit(dir, "knowledge/n.md", "", "x", ScopeNotes); err == nil {
t.Error("empty old_str must be rejected")
}
if _, err := Edit(dir, "knowledge/n.md", "alpha", "alpha", ScopeNotes); err == nil {
t.Error("a no-op edit must be rejected rather than burning a sync cycle")
}
}
func TestEdit_DeletionIsLegal(t *testing.T) {
dir := newBrain(t)
abs := seed(t, dir, "knowledge/n.md", "keep\ndrop\n")
if _, err := Edit(dir, "knowledge/n.md", "drop\n", "", ScopeNotes); err != nil {
t.Fatalf("Edit: %v", err)
}
if got := read(t, abs); got != "keep\n" {
t.Errorf("file = %q, want %q", got, "keep\n")
}
}
func TestEdit_ReportsWhenOnlyLineEndingsDiffer(t *testing.T) {
// The likeliest real failure: the caller's old_str lost its \r. Never
// normalise silently — that breaks byte-identity — but say so, because an
// opaque "no match" is unfixable from the caller's side.
dir := newBrain(t)
seed(t, dir, "knowledge/n.md", "alpha\r\nbeta\r\n")
_, err := Edit(dir, "knowledge/n.md", "alpha\nbeta", "x", ScopeNotes)
if err == nil {
t.Fatal("want a refusal")
}
if !strings.Contains(err.Error(), "line ending") {
t.Errorf("error should point at CRLF, got %v", err)
}
}
func TestEdit_RefusesWhileGitIsMidOperation(t *testing.T) {
// brain-sync runs `git merge -X ours origin/main`, and aborts on conflict
// with a hard reset. An edit landing inside that window is either erased
// or committed as a wrongly-resolved conflict, and the caller was told it
// succeeded.
dir := newBrain(t)
seed(t, dir, "knowledge/n.md", "alpha\n")
for _, marker := range []string{"MERGE_HEAD", "index.lock", "REBASE_HEAD"} {
t.Run(marker, func(t *testing.T) {
p := filepath.Join(dir, ".git", marker)
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Remove(p) }()
if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err == nil {
t.Errorf("edit accepted while %s exists", marker)
}
})
}
}
func TestEdit_RefusesAFileHoldingConflictMarkers(t *testing.T) {
dir := newBrain(t)
seed(t, dir, "knowledge/n.md", "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> origin/main\n")
if _, err := Edit(dir, "knowledge/n.md", "ours", "x", ScopeNotes); err == nil {
t.Fatal("editing one side of an unresolved conflict must be refused")
}
}
func TestEdit_ConcurrentEditsBothApply(t *testing.T) {
// Read-modify-write with no lock loses one update AND reports success for
// both. old_str uniqueness does not protect against it: uniqueness is
// evaluated against the stale read.
dir := newBrain(t)
abs := seed(t, dir, "knowledge/n.md", "alpha\nbeta\n")
var wg sync.WaitGroup
errs := make([]error, 2)
for i, pair := range [][2]string{{"alpha", "ALPHA"}, {"beta", "BETA"}} {
wg.Add(1)
go func(i int, old, new string) {
defer wg.Done()
_, errs[i] = Edit(dir, "knowledge/n.md", old, new, ScopeNotes)
}(i, pair[0], pair[1])
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("edit %d failed: %v", i, err)
}
}
if got := read(t, abs); got != "ALPHA\nBETA\n" {
t.Errorf("file = %q, want both edits applied", got)
}
}
func TestEdit_LeavesNoTemporaryFileBehind(t *testing.T) {
dir := newBrain(t)
seed(t, dir, "knowledge/n.md", "alpha\n")
if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(filepath.Join(dir, "knowledge"))
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.Name() != "note.md" && e.Name() != "n.md" {
t.Errorf("stray file left in the tree: %q (git add -A would commit it)", e.Name())
}
}
}
func TestEdit_PreservesReadableMode(t *testing.T) {
// Other uids write this tree (brain-fix, brain-enrich). os.CreateTemp
// yields 0600 and rename preserves it, which would break them silently.
dir := newBrain(t)
abs := seed(t, dir, "knowledge/n.md", "alpha\n")
if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(abs)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o644 {
t.Errorf("mode = %v, want 0644", fi.Mode().Perm())
}
}
func TestEdit_RefusesNonUTF8Replacement(t *testing.T) {
dir := newBrain(t)
seed(t, dir, "knowledge/n.md", "alpha\n")
if _, err := Edit(dir, "knowledge/n.md", "alpha", "bad \xff", ScopeNotes); err == nil {
t.Fatal("new_str with invalid UTF-8 must be refused, not written")
}
}