ZDDC/zddc/internal/handler/zddchandler_test.go
ZDDC 610b7ef65a feat(archive): periodic rescan + admin reindex endpoint
The fsnotify watcher only sees events the local kernel generates, so on
SMB/CIFS-backed roots (Azure Files) writes from any other client are
invisible — the archive index would silently miss them until pod
restart. Add two backstops:

1. Periodic full re-walk via Index.Rebuild on a configurable interval
   (--archive-rescan-interval / ZDDC_ARCHIVE_RESCAN_INTERVAL, default
   60s, 0 to disable). Atomically swaps ByProject under the existing
   RWMutex; concurrent reads stay safe.
2. Admin-only POST /.profile/reindex that triggers an immediate rebuild
   and returns {duration_ms, project_count, tracking_count}, for the
   "I just dropped 50 files and don't want to wait" case. Gated by
   IsAdmin with the same 404-on-non-admin pattern as the other admin
   sub-resources.

Tests: TestRebuild_PicksUpAddsAndDrops covers add+drop semantics and
returned counts; TestServeProfileReindexPOST covers the happy admin
path; matrix entries cover the gate (anonymous/non-admin → 404, admin
GET → 405 method-not-allowed since the route is POST-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:50:51 -05:00

437 lines
16 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// zddcTestSetup writes a tree of .zddc files and returns the root and a
// helper that builds requests with an injected user email. files keys
// are paths relative to root; the empty string is the root itself. Each
// path is created as a directory; if the value is non-empty it is
// written as that directory's .zddc.
func zddcTestSetup(t *testing.T, files map[string]string) (cfg config.Config, do func(method, target, email, body string) *httptest.ResponseRecorder) {
t.Helper()
root := t.TempDir()
for rel, body := range files {
dir := filepath.Join(root, rel)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
zddc.InvalidateCache(dir)
if body == "" {
continue
}
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
}
cfg = config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
do = func(method, target, email, body string) *httptest.ResponseRecorder {
var rdr *bytes.Reader
if body != "" {
rdr = bytes.NewReader([]byte(body))
}
var req *http.Request
if rdr != nil {
req = httptest.NewRequest(method, target, rdr)
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
return rec
}
return cfg, do
}
func TestServeZddcAuthGate(t *testing.T) {
// root admin = root@example.com; subtree admin alice@example.com on /projects.
cfg, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/x": "",
})
cases := []struct {
name string
method string
target string
email string
wantStatus int
}{
{"anon GET root", http.MethodGet, "/.profile/zddc?path=/", "", http.StatusNotFound},
{"non-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "mallory@example.com", http.StatusNotFound},
{"super-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "root@example.com", http.StatusOK},
{"subtree-admin GET root (read-only)", http.MethodGet, "/.profile/zddc?path=/", "alice@example.com", http.StatusOK},
{"subtree-admin GET own grant file (read-only)", http.MethodGet, "/.profile/zddc?path=/projects", "alice@example.com", http.StatusOK},
{"subtree-admin GET deeper", http.MethodGet, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
{"subtree-admin POST own grant file (forbidden)", http.MethodPost, "/.profile/zddc?path=/projects", "alice@example.com", http.StatusForbidden},
{"subtree-admin POST deeper (allowed)", http.MethodPost, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
{"super-admin POST root", http.MethodPost, "/.profile/zddc?path=/", "root@example.com", http.StatusOK},
{"non-admin POST anywhere", http.MethodPost, "/.profile/zddc?path=/projects/x", "mallory@example.com", http.StatusNotFound},
{"DELETE root rejected", http.MethodDelete, "/.profile/zddc?path=/", "root@example.com", http.StatusBadRequest},
{"super-admin DELETE leaf", http.MethodDelete, "/.profile/zddc?path=/projects/x", "root@example.com", http.StatusNoContent},
}
_ = cfg
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := ""
if tc.method == http.MethodPost {
if tc.target == "/.profile/zddc?path=/" {
// Root POST: writer must remain in admins list.
body = `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com"]}`
} else {
body = `{"title":"x","acl":{"allow":["*@example.com"],"deny":[]},"admins":[]}`
}
}
rec := do(tc.method, tc.target, tc.email, body)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d; body=%s", rec.Code, tc.wantStatus, rec.Body.String())
}
})
}
}
func TestServeZddcGetReturnsChain(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\nacl:\n allow: [\"*@example.com\"]\n",
"projects": "title: All Projects\n",
"projects/sub": "title: Substation\n",
})
rec := do(http.MethodGet, "/.profile/zddc?path=/projects/sub", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var resp zddcGetResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Path != "/projects/sub" {
t.Errorf("path = %q, want /projects/sub", resp.Path)
}
if !resp.CanEdit {
t.Errorf("CanEdit = false; root admin should edit anywhere")
}
if !resp.Exists {
t.Errorf("Exists = false but file was written")
}
if len(resp.EffectiveChain) != 3 {
t.Fatalf("chain length = %d, want 3 (root, projects, projects/sub)", len(resp.EffectiveChain))
}
if resp.EffectiveChain[0].Dir != "/" {
t.Errorf("chain[0].Dir = %q, want /", resp.EffectiveChain[0].Dir)
}
if resp.EffectiveChain[1].Dir != "/projects" {
t.Errorf("chain[1].Dir = %q, want /projects", resp.EffectiveChain[1].Dir)
}
if resp.EffectiveChain[2].Title != "Substation" {
t.Errorf("chain[2].Title = %q, want Substation", resp.EffectiveChain[2].Title)
}
}
func TestServeZddcPostValidatesGlob(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "",
})
body := `{"title":"x","acl":{"allow":["alice@@bad","good@example.com"],"deny":[]},"admins":[]}`
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
var we writeError
if err := json.Unmarshal(rec.Body.Bytes(), &we); err != nil {
t.Fatalf("decode err body: %v", err)
}
if len(we.Errors) == 0 || we.Errors[0].Field != "acl.allow[0]" {
t.Errorf("expected acl.allow[0] error, got %+v", we.Errors)
}
}
func TestServeZddcRootSelfDemotionRejected(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n - bob@example.com\n",
})
// root tries to remove themselves, leaving only bob.
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["bob@example.com"]}`
rec := do(http.MethodPost, "/.profile/zddc?path=/", "root@example.com", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (self-demotion rejected); body=%s", rec.Code, rec.Body.String())
}
}
func TestServeZddcRootKeepingSelfAccepted(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
})
// root adds bob alongside themselves — fine.
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com","bob@example.com"]}`
rec := do(http.MethodPost, "/.profile/zddc?path=/", "root@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestServeZddcWriteRoundTrip(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "",
})
body := `{"title":"Engineering","acl":{"allow":["*@varasys.io"],"deny":[]},"admins":["alice@varasys.io"]}`
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("write status = %d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/.profile/zddc?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("get status = %d body=%s", rec.Code, rec.Body.String())
}
var resp zddcGetResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.File.Title != "Engineering" {
t.Errorf("title round-trip = %q, want Engineering", resp.File.Title)
}
if len(resp.File.Admins) != 1 || resp.File.Admins[0] != "alice@varasys.io" {
t.Errorf("admins round-trip = %v, want [alice@varasys.io]", resp.File.Admins)
}
}
func TestServeZddcWriteAppsRoundTrip(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "",
})
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":[],"apps":{` +
`"default":"https://zddc.varasys.io/releases:stable",` +
`"classifier":":beta",` +
`"archive":"https://my.local.stuff/releases"}}`
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("write status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/.profile/zddc?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String())
}
var resp zddcGetResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if got := resp.File.Apps["default"]; got != "https://zddc.varasys.io/releases:stable" {
t.Errorf("default round-trip = %q", got)
}
if got := resp.File.Apps["classifier"]; got != ":beta" {
t.Errorf("classifier round-trip = %q", got)
}
if got := resp.File.Apps["archive"]; got != "https://my.local.stuff/releases" {
t.Errorf("archive round-trip = %q", got)
}
}
func TestServeZddcWriteAppsRejectsBadSpec(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "",
})
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":[],"apps":{"archive":"this is garbage"}}`
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d (want 400)", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"apps.archive"`) {
t.Errorf("expected per-field error for apps.archive; got %s", rec.Body.String())
}
}
func TestServeZddcEditorRendersAppsSection(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "apps:\n default: \":beta\"\n classifier: \"v0.0.4\"\n",
})
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
"Apps (tool HTML sources)",
`data-apps-key="default"`,
`data-apps-key="archive"`,
`data-apps-key="classifier"`,
`data-apps-key="mdedit"`,
`data-apps-key="transmittal"`,
`data-apps-key="landing"`,
`value=":beta"`,
`value="v0.0.4"`,
"classifier_v0.0.4.html", // preview reflects the cascaded resolution
} {
if !strings.Contains(body, want) {
t.Errorf("editor body missing %q", want)
}
}
}
func TestServeZddcTreeFiltersByVisibility(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"alpha": "admins:\n - alice@example.com\n",
"alpha/x": "title: alpha-x\n",
"beta": "admins:\n - bob@example.com\n",
})
// alice sees alpha (her grant) and alpha/x (descendant), but not beta.
rec := do(http.MethodGet, "/.profile/zddc/tree", "alice@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var entries []treeEntry
if err := json.Unmarshal(rec.Body.Bytes(), &entries); err != nil {
t.Fatalf("decode: %v", err)
}
seen := map[string]bool{}
for _, e := range entries {
seen[e.Path] = true
}
if !seen["/alpha"] || !seen["/alpha/x"] {
t.Errorf("alice should see /alpha and /alpha/x; got %v", seen)
}
if seen["/beta"] {
t.Errorf("alice should NOT see /beta; got %v", seen)
}
}
func TestServeZddcEditorRenders(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "title: Engineering\n",
})
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Engineering") {
t.Errorf("editor should pre-fill title; body did not contain 'Engineering'")
}
if !strings.Contains(body, "/.profile/zddc?path=") {
t.Errorf("editor should reference API URL; body lacks /.profile/zddc?path=")
}
if !strings.Contains(body, "Subtree admins of /projects") {
t.Errorf("editor should label admins section as subtree (not bootstrap) for non-root file")
}
}
func TestServeZddcEditorReadOnlyForNonEditor(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
})
// alice viewing her own grant file: read-only.
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "alice@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Read-only") {
t.Errorf("editor should show Read-only banner for non-editor; body lacks it")
}
}
func TestServeZddcRejectsReservedPathSegments(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
})
for _, p := range []string{"/.foo", "/_bar", "/projects/.evil"} {
rec := do(http.MethodGet, "/.profile/zddc?path="+p, "root@example.com", "")
if rec.Code != http.StatusNotFound {
t.Errorf("path=%q expected 404, got %d", p, rec.Code)
}
}
}
func TestServeZddcAdminDispatchUnchangedForOtherRoutes(t *testing.T) {
// Confirm that putting /.profile/zddc/* under the broader gate did not
// regress the super-admin gate on /.profile/whoami etc.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeProfile(cfg, nil, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("non-admin /.profile/whoami got %d, want 404", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec = httptest.NewRecorder()
ServeProfile(cfg, nil, nil, rec, req)
if rec.Code != http.StatusOK {
t.Errorf("super-admin /.profile/whoami got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestServeZddcAssetsCustomCSS(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
if err := os.WriteFile(filepath.Join(root, ".admin.css"), []byte("body { color: red; }"), 0o644); err != nil {
t.Fatalf("write .admin.css: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.profile/zddc/assets/custom.css", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
t.Errorf("Content-Type = %q, want text/css...", ct)
}
if !strings.Contains(rec.Body.String(), "color: red") {
t.Errorf("body does not contain custom CSS")
}
}
func TestServeZddcAssetsAbsentReturns404(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.profile/zddc/assets/custom.css", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status=%d, want 404", rec.Code)
}
}