A history: true .zddc subtree (enabled by default on archive/<party>/working/)
routes markdown PUTs through WriteTextWithHistory: each save snapshots the
content into a hidden, immutable .history/<stem>/ store (content-addressed
blobs + an append-only log.jsonl carrying server-stamped {ts, email, sha,
prev}) before writing the live file. The live file at its natural path stays
the source of truth; no symlinks, no audit in the body/filename.
Reads: GET <file>?history=1 lists versions (newest-first, current flagged);
GET <file>?history=<sha> returns that version's bytes (hex-id guard against
traversal). Listings carry a per-file History flag so the browse client knows
where to offer the affordance.
History is subtree-inheriting and ignores inherit:false ACL fences (versioning
is a write behavior, not a permission), so fenced per-user homes under working/
are covered too. No-op saves dedup; pre-existing files lazy-seed their origin
version. Records (.yaml) keep their existing in-body-audit history path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
7 KiB
Go
221 lines
7 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)
|
|
}
|
|
}
|
|
|
|
func countBlobs(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")
|
|
sha1 := fileETag([]byte("v1"))
|
|
sha2 := fileETag([]byte("v2"))
|
|
|
|
// ── create ──
|
|
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].Sha != sha1 {
|
|
t.Errorf("sha = %q, want %q", entries[0].Sha, sha1)
|
|
}
|
|
if !entries[0].Current {
|
|
t.Errorf("v1 should be current")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(histDir, sha1+".md")); err != nil {
|
|
t.Errorf("v1 blob missing: %v", err)
|
|
}
|
|
|
|
// ── update ──
|
|
time.Sleep(2 * time.Millisecond) // distinct RFC3339Nano ts 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))
|
|
}
|
|
// newest first
|
|
if entries[0].Sha != sha2 || !entries[0].Current {
|
|
t.Errorf("head = %+v, want v2 current", entries[0])
|
|
}
|
|
if entries[0].Prev != sha1 {
|
|
t.Errorf("v2.prev = %q, want %q", entries[0].Prev, sha1)
|
|
}
|
|
if entries[1].Sha != sha1 || entries[1].Current {
|
|
t.Errorf("tail = %+v, want v1 non-current", entries[1])
|
|
}
|
|
if entries[1].By != "alice@x.com" {
|
|
t.Errorf("v1 author lost: %q", entries[1].By)
|
|
}
|
|
|
|
// ── no-op save (identical content) → dedup, no new entry ──
|
|
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
|
|
entries, _ = ListMdHistory(abs)
|
|
if len(entries) != 2 {
|
|
t.Fatalf("dedup failed: want 2 entries, got %d", len(entries))
|
|
}
|
|
|
|
// ── restore v1 content → new log entry, blob reused ──
|
|
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].Sha != sha1 || !entries[0].Current || entries[0].By != "carol@x.com" {
|
|
t.Errorf("head = %+v, want restored v1 by carol, current", entries[0])
|
|
}
|
|
// Only the newest matching entry is current, even though the oldest
|
|
// entry has the same sha.
|
|
if entries[2].Current {
|
|
t.Errorf("oldest v1 entry should not be current: %+v", entries[2])
|
|
}
|
|
// Content-addressed: only two distinct blobs (v1, v2) despite 3 saves.
|
|
if n := countBlobs(t, histDir); n != 2 {
|
|
t.Errorf("distinct blobs = %d, want 2", 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")))
|
|
|
|
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" || entries[0].Sha != fileETag([]byte("edited")) {
|
|
t.Errorf("head = %+v, want edit by dave", entries[0])
|
|
}
|
|
if entries[1].By != "" || entries[1].Sha != fileETag([]byte("legacy")) {
|
|
t.Errorf("seed = %+v, want legacy with empty author", entries[1])
|
|
}
|
|
}
|
|
|
|
func TestWriteTextWithHistory_EmptyAuthorAnonymous(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 != "anonymous" {
|
|
t.Fatalf("empty author should record anonymous, 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"))
|
|
sha1 := fileETag([]byte("one"))
|
|
|
|
// ── list ──
|
|
req := httptest.NewRequest(http.MethodGet, "/page.md?history=1", nil)
|
|
rec := httptest.NewRecorder()
|
|
ServeTextHistory(rec, req, 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 ──
|
|
req = httptest.NewRequest(http.MethodGet, "/page.md?history="+sha1, nil)
|
|
rec = httptest.NewRecorder()
|
|
ServeTextHistory(rec, req, abs, sha1)
|
|
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", "deadbeef.md"} {
|
|
req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil)
|
|
rec := httptest.NewRecorder()
|
|
ServeTextHistory(rec, req, 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 (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, yamlAbs, "1")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("non-md status = %d, want 404", rec.Code)
|
|
}
|
|
}
|