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) } }