Review caught a real bypass in the #111 fix before merge: the previous commit validated only the frontmatter fence the caller supplied, not the merged result that actually reaches disk. A caller could blank claim_provenance alone (absent per claim.present's "empty is unset" rule) while half_life/applies survived untouched from the prior note — each side individually looked fine, the merged envelope on disk was the unprovenanced one the whole check exists to catch. That's the exact laundering #111 named as the worse of its two gaps. Fixed by computing the envelope from fm (post-merge, pre-write) instead of contentFM (the caller's raw fragment). Flip-tested: reverting to fragment-only validation turns the new adversarial test red; the merged validation turns it green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
239 lines
8.5 KiB
Go
239 lines
8.5 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 TestUpdateNoteRejectsClearingProvenanceWhileEnvelopeSurvives(t *testing.T) {
|
|
// Found in review, not written speculatively: validating only the
|
|
// caller's fragment (rather than the merged result) let a caller blank
|
|
// out claim_provenance alone while half_life/applies survived from the
|
|
// prior note untouched — each side looks fine on its own, the merged
|
|
// envelope on disk is the unprovenanced one the whole check exists to
|
|
// catch.
|
|
brainDir := t.TempDir()
|
|
original := "---\nwing: jepa-fx\nhall: facts\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nold\n"
|
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
|
|
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
|
Path: rel,
|
|
Content: "---\nclaim_provenance: \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 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
|
|
}
|