hyperguild#111: writeHallNote validates half_life/applies/claim_provenance before a note is born; UpdateNote's supersede path didn't, so the fields that need provenance most (a whole-note regenerate is more likely to carry model-authored frontmatter than a fresh create) went unchecked. Fixed by extracting any frontmatter fence the caller leads Content with, validating it with the same claim.ValidateEnvelope, and merging its fields into the restamped frontmatter instead of leaving it as a second, inert `---`-fenced block in the body — that merge was a real gap independent of validation: a caller's supersede-time frontmatter was previously silently dropped, never taking effect at all. Reject leaves the note byte-identical (fail-closed, nothing written). Fence-less content (the common case today) is unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
216 lines
7.4 KiB
Go
216 lines
7.4 KiB
Go
package api
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// seedNote writes a note directly to disk and returns its relPath.
|
|
func seedNote(t *testing.T, brainDir, rel, content string) string {
|
|
t.Helper()
|
|
full := filepath.Join(brainDir, filepath.FromSlash(rel))
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
|
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
|
|
return rel
|
|
}
|
|
|
|
func TestUpdateNoteSupersedesAndStamps(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
|
"---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\ncustom: keep-me\n---\n# Old\n\nold body\n")
|
|
|
|
relPath, hash, priorHash, err := UpdateNote(brainDir, UpdateNoteOptions{
|
|
Path: rel,
|
|
Content: "# New\n\nnew body\n",
|
|
Reason: "facts changed",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, rel, relPath)
|
|
assert.NotEmpty(t, hash)
|
|
assert.NotEmpty(t, priorHash)
|
|
assert.NotEqual(t, hash, priorHash)
|
|
|
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
|
require.NoError(t, err)
|
|
s := string(got)
|
|
// Body replaced.
|
|
assert.Contains(t, s, "# New")
|
|
assert.NotContains(t, s, "old body")
|
|
// Prior fields preserved.
|
|
assert.Contains(t, s, "wing: jepa-fx")
|
|
assert.Contains(t, s, "hall: facts")
|
|
assert.Contains(t, s, "created_at: 2026-01-01T00:00:00Z")
|
|
assert.Contains(t, s, "custom: keep-me")
|
|
// Supersession stamped.
|
|
assert.Contains(t, s, "updated_at:")
|
|
assert.Contains(t, s, "supersedes: "+priorHash)
|
|
assert.Contains(t, s, "supersede_reason: facts changed")
|
|
}
|
|
|
|
func TestUpdateNoteRejectsInvalidEnvelopeAndWritesNothing(t *testing.T) {
|
|
// hyperguild#111: WriteNote validates the claim envelope before create;
|
|
// UpdateNote didn't before supersede, so the same rule must hold on
|
|
// change as on birth, fail-closed, nothing written on rejection.
|
|
brainDir := t.TempDir()
|
|
original := "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\n# Old\n\nold body\n"
|
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
|
|
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
|
Path: rel,
|
|
Content: "---\nhalf_life: forever\n---\n# New\n\nnew body\n",
|
|
})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "claim envelope")
|
|
|
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, original, string(got), "rejected update must leave the note byte-identical")
|
|
}
|
|
|
|
func TestUpdateNoteRejectsAppliesWithoutProvenance(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
original := "---\nwing: jepa-fx\nhall: facts\n---\nold\n"
|
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
|
|
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
|
Path: rel,
|
|
Content: "---\napplies: universal\n---\nnew\n",
|
|
})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "claim envelope")
|
|
|
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, original, string(got))
|
|
}
|
|
|
|
func TestUpdateNoteAcceptsValidEnvelopeAndMergesIntoFrontmatter(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
|
"---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\nold\n")
|
|
|
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
|
Path: rel,
|
|
Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nnew body\n",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
|
require.NoError(t, err)
|
|
s := string(got)
|
|
assert.Contains(t, s, "half_life: durable")
|
|
assert.Contains(t, s, "applies: universal")
|
|
assert.Contains(t, s, "claim_provenance: asserted")
|
|
assert.Contains(t, s, "wing: jepa-fx", "prior fields survive")
|
|
assert.Contains(t, s, "new body")
|
|
assert.Equal(t, 2, strings.Count(s, "---\n"),
|
|
"exactly one open+close fence pair — the caller's own fence must not leak in as a second, inert block")
|
|
}
|
|
|
|
func TestUpdateNoteWithoutEnvelopeFieldsIsUnchangedFromToday(t *testing.T) {
|
|
// Regression pin: plain fence-less content (the common case) must behave
|
|
// exactly as it did before the envelope-merge fix.
|
|
brainDir := t.TempDir()
|
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
|
"---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\ncustom: keep-me\n---\n# Old\n\nold body\n")
|
|
|
|
relPath, hash, priorHash, err := UpdateNote(brainDir, UpdateNoteOptions{
|
|
Path: rel,
|
|
Content: "# New\n\nnew body\n",
|
|
Reason: "facts changed",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, rel, relPath)
|
|
assert.NotEmpty(t, hash)
|
|
assert.NotEqual(t, hash, priorHash)
|
|
|
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
|
require.NoError(t, err)
|
|
s := string(got)
|
|
assert.Contains(t, s, "# New")
|
|
assert.Contains(t, s, "custom: keep-me")
|
|
assert.Contains(t, s, "supersedes: "+priorHash)
|
|
}
|
|
|
|
func TestUpdateNoteResolvesByWingHallSlug(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
|
"---\nwing: jepa-fx\nhall: facts\n---\nold\n")
|
|
|
|
relPath, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
|
Wing: "jepa-fx", Hall: "facts", Slug: "val-vol",
|
|
Content: "new\n",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "wiki/jepa-fx/facts/val-vol.md", relPath)
|
|
}
|
|
|
|
func TestUpdateNoteErrorsOnMissingAndDoesNotCreate(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
|
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
|
Wing: "jepa-fx", Hall: "facts", Slug: "ghost",
|
|
Content: "x\n",
|
|
})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "does not exist")
|
|
|
|
// No file created.
|
|
_, statErr := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/facts/ghost.md"))
|
|
assert.True(t, os.IsNotExist(statErr), "missing-target update must not create a note")
|
|
}
|
|
|
|
func TestUpdateNoteRejectsTraversal(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
|
Path: "../escape.md",
|
|
Content: "x\n",
|
|
})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestReadNoteReturnsFrontmatterBodyHash(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/n.md",
|
|
"---\nwing: jepa-fx\nhall: facts\n---\n# Body\n\ntext\n")
|
|
|
|
fm, body, hash, err := ReadNote(brainDir, rel)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "jepa-fx", fm["wing"])
|
|
assert.Equal(t, "facts", fm["hall"])
|
|
assert.Equal(t, "# Body\n\ntext\n", body)
|
|
|
|
// Hash matches ContentHash of the raw bytes on disk (round-trip).
|
|
raw, _ := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
|
assert.Equal(t, ContentHash(raw), hash)
|
|
}
|
|
|
|
func TestReadNoteRejectsTraversal(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
_, _, _, err := ReadNote(brainDir, "../../etc/passwd")
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestUpdateThenReadRoundTripsHash(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
rel := seedNote(t, brainDir, "wiki/a/facts/n.md", "---\nwing: a\nhall: facts\n---\nold\n")
|
|
|
|
_, hash, _, err := UpdateNote(brainDir, UpdateNoteOptions{Path: rel, Content: "new\n"})
|
|
require.NoError(t, err)
|
|
|
|
_, _, readHash, err := ReadNote(brainDir, rel)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, hash, readHash, "update content_hash must round-trip through ReadNote")
|
|
}
|
|
|
|
func TestContentHashStable(t *testing.T) {
|
|
assert.Equal(t, ContentHash([]byte("abc")), ContentHash([]byte("abc")))
|
|
assert.NotEqual(t, ContentHash([]byte("abc")), ContentHash([]byte("abd")))
|
|
assert.True(t, strings.HasPrefix(ContentHash([]byte("")), "")) // hex, non-panicking
|
|
}
|