ZDDC/zddc/internal/handler/mdhistory_test.go
ZDDC 1eeaa1bd96 refactor(zddc): centralize canonical-slot registry; feat: history_globs cascade key
Two cleanups from the hard-coded-vs-cascade audit:

#2 Centralize the canonical slot names. The lists {ssr,mdl,rsk,working,
staging,reviewing} and the per-party {incoming,received,issued,mdl,rsk,
working,staging,reviewing} were hand-written across ensure.go (×2),
fileapi.go (×2), virtualviews.go, lookups.go. New internal/zddc/slots.go is
the single registry with IsRowSlot/IsFolderNavSlot/IsVirtualAggregatorSlot/
IsPerPartySlot; virtualViewRE is built from it. Slot NAMES stay hard-coded
(they carry bespoke behavior) but now live in one place — adding/adjusting a
slot is one edit, not a hunt. Pure refactor; behavior unchanged.

#1 Make the history file-type selection cascade-driven. IsTextHistoryCandidate
hard-coded ".md"; now it matches the effective history_globs from the .zddc
cascade (default ["*.md"], widen per-deployment e.g. ["*.md","*.txt"]). New
ZddcFile.HistoryGlobs + mergeOverlay + PolicyChain.EffectiveHistoryGlobs +
HistoryGlobsAt, threaded through serveFilePut/serveFileMove/dispatch and
ServeTextHistory (now takes fsRoot). The history: bool still gates whether
snapshots are recorded; history_globs only says which file types qualify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:50:53 -05:00

252 lines
8.5 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
func mustNoErr(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
// countSnapshots counts the per-save history files in histDir.
func countSnapshots(t *testing.T, histDir string) int {
t.Helper()
ents, err := os.ReadDir(histDir)
if err != nil {
t.Fatalf("read history dir: %v", err)
}
n := 0
for _, e := range ents {
if strings.HasSuffix(e.Name(), ".md") {
n++
}
}
return n
}
func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "notes.md")
histDir := filepath.Join(dir, ".history", "notes")
// ── create: one snapshot, authored, current ──
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com"))
if b, _ := os.ReadFile(abs); string(b) != "v1" {
t.Fatalf("live = %q, want v1", b)
}
entries, err := ListMdHistory(abs)
mustNoErr(t, err)
if len(entries) != 1 {
t.Fatalf("after create: want 1 entry, got %d", len(entries))
}
if entries[0].By != "alice@x.com" {
t.Errorf("by = %q, want alice@x.com", entries[0].By)
}
if !entries[0].Current {
t.Errorf("v1 should be current")
}
if entries[0].ID == "" || !strings.HasSuffix(entries[0].ID, "-alice@x.com.md") {
t.Errorf("id = %q, want a <ts>-alice@x.com.md snapshot name", entries[0].ID)
}
if _, err := os.Stat(filepath.Join(histDir, entries[0].ID)); err != nil {
t.Errorf("v1 snapshot missing: %v", err)
}
// ── update: second snapshot, newest-first, current moves ──
time.Sleep(2 * time.Millisecond) // distinct timestamp for ordering
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
if b, _ := os.ReadFile(abs); string(b) != "v2" {
t.Fatalf("live = %q, want v2", b)
}
entries, _ = ListMdHistory(abs)
if len(entries) != 2 {
t.Fatalf("after update: want 2 entries, got %d", len(entries))
}
if entries[0].By != "bob@x.com" || !entries[0].Current {
t.Errorf("head = %+v, want v2 by bob, current", entries[0])
}
if entries[1].By != "alice@x.com" || entries[1].Current {
t.Errorf("tail = %+v, want v1 by alice, non-current", entries[1])
}
// ── no-op save (identical to live) → no new snapshot ──
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
if n := countSnapshots(t, histDir); n != 2 {
t.Fatalf("dedup failed: want 2 snapshots, got %d", n)
}
// ── restore v1 content → a NEW snapshot (every save is its own file) ──
time.Sleep(2 * time.Millisecond)
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "carol@x.com"))
if b, _ := os.ReadFile(abs); string(b) != "v1" {
t.Fatalf("live = %q, want restored v1", b)
}
entries, _ = ListMdHistory(abs)
if len(entries) != 3 {
t.Fatalf("after restore: want 3 entries, got %d", len(entries))
}
if entries[0].By != "carol@x.com" || !entries[0].Current {
t.Errorf("head = %+v, want restored v1 by carol, current", entries[0])
}
// Only the newest matching-content entry is current, even though the
// oldest snapshot has the same bytes.
if entries[2].Current {
t.Errorf("oldest v1 entry should not be current: %+v", entries[2])
}
if n := countSnapshots(t, histDir); n != 3 {
t.Errorf("snapshots = %d, want 3 (one file per save)", n)
}
}
func TestWriteTextWithHistory_LazySeedPreexisting(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "doc.md")
// Simulate a file that existed before history was enabled.
mustNoErr(t, zddc.WriteAtomic(abs, []byte("legacy")))
time.Sleep(2 * time.Millisecond) // keep the seed's mtime stamp < the edit
mustNoErr(t, WriteTextWithHistory(abs, []byte("edited"), "dave@x.com"))
entries, err := ListMdHistory(abs)
mustNoErr(t, err)
if len(entries) != 2 {
t.Fatalf("lazy-seed: want 2 entries (seeded prior + new), got %d", len(entries))
}
// newest = the edit; oldest = the seeded legacy version (author unknown)
if entries[0].By != "dave@x.com" {
t.Errorf("head = %+v, want edit by dave", entries[0])
}
if entries[1].By != "unknown" {
t.Errorf("seed = %+v, want legacy with 'unknown' author", entries[1])
}
}
func TestWriteTextWithHistory_EmptyAuthorUnknown(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "x.md")
mustNoErr(t, WriteTextWithHistory(abs, []byte("a"), ""))
entries, _ := ListMdHistory(abs)
if len(entries) != 1 || entries[0].By != "unknown" {
t.Fatalf("empty author should record 'unknown', got %+v", entries)
}
}
func TestServeTextHistory_ListAndVersion(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "page.md")
mustNoErr(t, WriteTextWithHistory(abs, []byte("one"), "a@x.com"))
time.Sleep(2 * time.Millisecond)
mustNoErr(t, WriteTextWithHistory(abs, []byte("two"), "b@x.com"))
// ── list ──
req := httptest.NewRequest(http.MethodGet, "/page.md?history=1", nil)
rec := httptest.NewRecorder()
ServeTextHistory(rec, req, dir, abs, "1")
if rec.Code != http.StatusOK {
t.Fatalf("list status = %d", rec.Code)
}
var got []MdHistoryEntry
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("list body not JSON: %v", err)
}
if len(got) != 2 || got[0].By != "b@x.com" {
t.Fatalf("list = %+v, want 2 newest-first", got)
}
// ── specific version content (oldest = "one") ──
oldID := got[1].ID
req = httptest.NewRequest(http.MethodGet, "/page.md?history="+url.QueryEscape(oldID), nil)
rec = httptest.NewRecorder()
ServeTextHistory(rec, req, dir, abs, oldID)
if rec.Code != http.StatusOK {
t.Fatalf("version status = %d", rec.Code)
}
if rec.Body.String() != "one" {
t.Errorf("version body = %q, want %q", rec.Body.String(), "one")
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
t.Errorf("content-type = %q", ct)
}
}
func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "p.md")
mustNoErr(t, WriteTextWithHistory(abs, []byte("x"), "a@x.com"))
// Drop a secret in the parent so a successful traversal would be visible.
mustNoErr(t, zddc.WriteAtomic(filepath.Join(dir, "secret"), []byte("TOPSECRET")))
for _, bad := range []string{"../secret", "..%2Fsecret", "abc/def", "ZZZ", "nope.md"} {
req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil)
rec := httptest.NewRecorder()
ServeTextHistory(rec, req, dir, abs, bad)
if rec.Code == http.StatusOK {
t.Errorf("version %q unexpectedly served: body=%q", bad, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "TOPSECRET") {
t.Fatalf("traversal leaked secret for input %q", bad)
}
}
// Non-markdown path → 404 (text history not applicable).
yamlAbs := filepath.Join(dir, "rec.yaml")
req := httptest.NewRequest(http.MethodGet, "/rec.yaml?history=1", nil)
rec := httptest.NewRecorder()
ServeTextHistory(rec, req, dir, yamlAbs, "1")
if rec.Code != http.StatusNotFound {
t.Errorf("non-md status = %d, want 404", rec.Code)
}
}
// TestWriteTextWithHistory_RenameDirOnMove is covered at the handler level
// (serveFileMove); here we only assert the snapshot filenames are SMB-safe
// (no colons) so they're valid on the Azure Files share.
func TestWriteTextWithHistory_SnapshotNamesAreSMBSafe(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "n.md")
mustNoErr(t, WriteTextWithHistory(abs, []byte("a"), "cwitt@burnsmcd.com"))
entries, _ := ListMdHistory(abs)
if len(entries) != 1 {
t.Fatalf("want 1 entry, got %d", len(entries))
}
if strings.ContainsAny(entries[0].ID, ":\\/") {
t.Errorf("snapshot id %q contains a char invalid on SMB", entries[0].ID)
}
}
// TestIsTextHistoryCandidate_CascadeGlobs — which file types qualify for
// text history is cascade-driven (history_globs), defaulting to *.md.
func TestIsTextHistoryCandidate_CascadeGlobs(t *testing.T) {
dir := t.TempDir()
// Default (no .zddc): *.md only.
if !IsTextHistoryCandidate(dir, filepath.Join(dir, "a.md")) {
t.Errorf("default: .md should be a candidate")
}
if IsTextHistoryCandidate(dir, filepath.Join(dir, "a.txt")) {
t.Errorf("default: .txt should NOT be a candidate")
}
// Override via .zddc history_globs in a subtree.
sub := filepath.Join(dir, "notes")
mustNoErr(t, os.MkdirAll(sub, 0o755))
mustNoErr(t, zddc.WriteAtomic(filepath.Join(sub, ".zddc"), []byte("history_globs: [\"*.txt\", \"*.md\"]\n")))
zddc.InvalidateCache(dir)
if !IsTextHistoryCandidate(dir, filepath.Join(sub, "a.txt")) {
t.Errorf("override: .txt should be a candidate under history_globs")
}
if !IsTextHistoryCandidate(dir, filepath.Join(sub, "a.md")) {
t.Errorf("override: .md still a candidate")
}
}