package handler import ( "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // profileTestRoot creates a temp dir, writes a .zddc with the given admins // list, and returns a Config pointing at it. func profileTestRoot(t *testing.T, admins []string) (config.Config, *LogRing) { t.Helper() root := t.TempDir() if len(admins) > 0 { var b strings.Builder b.WriteString("admins:\n") for _, a := range admins { b.WriteString(" - \"") b.WriteString(a) b.WriteString("\"\n") } if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(b.String()), 0o644); err != nil { t.Fatalf("write .zddc: %v", err) } zddc.InvalidateCache(root) } return config.Config{ Root: root, Addr: ":8443", EmailHeader: "X-Auth-Request-Email", }, NewLogRing(50) } // requestWithEmail builds a request whose context already carries email (as // the real ACLMiddleware would inject) and whose path is path. func requestWithEmail(method, path, email string) *http.Request { r := httptest.NewRequest(method, path, nil) if email != "" { r.Header.Set("X-Auth-Request-Email", email) ctx := context.WithValue(r.Context(), EmailKey, email) r = r.WithContext(ctx) } return r } // TestServeProfileGateMatrix checks the authorization decisions for every // sub-route. The page itself (/.profile/) is reachable to anyone (anonymous // included); admin-only sub-resources stay 404 for non-eligible callers, // preserving the existence-leakage policy on a per-resource basis. func TestServeProfileGateMatrix(t *testing.T) { cfg, ring := profileTestRoot(t, []string{"alice@example.com"}) cases := []struct { name string path string email string wantStatus int }{ // /.profile/ itself — public landing for everyone. {"anonymous /.profile/", "/.profile/", "", http.StatusOK}, {"non-admin /.profile/", "/.profile/", "bob@example.com", http.StatusOK}, {"admin /.profile/", "/.profile/", "alice@example.com", http.StatusOK}, // /.profile/access — JSON, also public. {"anonymous /.profile/access", "/.profile/access", "", http.StatusOK}, {"admin /.profile/access", "/.profile/access", "alice@example.com", http.StatusOK}, // Admin-only sub-resources — 404 for non-eligible callers. {"anonymous /.profile/whoami", "/.profile/whoami", "", http.StatusNotFound}, {"anonymous /.profile/config", "/.profile/config", "", http.StatusNotFound}, {"anonymous /.profile/logs", "/.profile/logs", "", http.StatusNotFound}, {"non-admin /.profile/whoami", "/.profile/whoami", "bob@example.com", http.StatusNotFound}, {"non-admin /.profile/config", "/.profile/config", "bob@example.com", http.StatusNotFound}, {"non-admin /.profile/logs", "/.profile/logs", "bob@example.com", http.StatusNotFound}, {"admin /.profile/whoami", "/.profile/whoami", "alice@example.com", http.StatusOK}, {"admin /.profile/config", "/.profile/config", "alice@example.com", http.StatusOK}, {"admin /.profile/logs", "/.profile/logs", "alice@example.com", http.StatusOK}, // Unknown sub-route still 404. {"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rec := httptest.NewRecorder() ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, tc.path, tc.email)) if rec.Code != tc.wantStatus { t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body.String()) } }) } } func TestServeProfileWhoamiPayload(t *testing.T) { cfg, ring := profileTestRoot(t, []string{"alice@example.com"}) rec := httptest.NewRecorder() r := requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com") r.Header.Set("X-Other-Header", "hi there") ServeProfile(cfg, ring, rec, r) if rec.Code != 200 { t.Fatalf("status = %d", rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Errorf("Content-Type = %q, want application/json", ct) } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String()) } if got["configured_email_header"] != "X-Auth-Request-Email" { t.Errorf("configured_email_header = %v", got["configured_email_header"]) } if got["observed_email"] != "alice@example.com" { t.Errorf("observed_email = %v", got["observed_email"]) } if got["resolved_email"] != "alice@example.com" { t.Errorf("resolved_email = %v", got["resolved_email"]) } headers, _ := got["headers"].(map[string]any) if _, ok := headers["X-Auth-Request-Email"]; !ok { t.Errorf("headers map missing X-Auth-Request-Email: %+v", headers) } if _, ok := headers["X-Other-Header"]; !ok { t.Errorf("headers map missing X-Other-Header: %+v", headers) } } func TestServeProfileConfigPayload(t *testing.T) { cfg, ring := profileTestRoot(t, []string{"alice@example.com"}) cfg.LogLevel = "info" cfg.IndexPath = ".archive" cfg.CORSOrigins = []string{"https://zddc.varasys.io"} rec := httptest.NewRecorder() ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/config", "alice@example.com")) if rec.Code != 200 { t.Fatalf("status = %d", rec.Code) } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("invalid JSON: %v", err) } for _, want := range []string{"root", "addr", "email_header", "log_level", "cors_origins"} { if _, ok := got[want]; !ok { t.Errorf("config payload missing key %q: %+v", want, got) } } if got["email_header"] != "X-Auth-Request-Email" { t.Errorf("email_header = %v", got["email_header"]) } } func TestServeProfileLogsPayload(t *testing.T) { cfg, ring := profileTestRoot(t, []string{"alice@example.com"}) rh := NewRingHandler(ring, slog.LevelDebug) logger := slog.New(rh) logger.Info("first") logger.Warn("second", "code", 42) rec := httptest.NewRecorder() ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/logs", "alice@example.com")) if rec.Code != 200 { t.Fatalf("status = %d", rec.Code) } var got []map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String()) } if len(got) != 2 { t.Fatalf("entries = %d, want 2", len(got)) } if got[0]["message"] != "first" || got[1]["message"] != "second" { t.Errorf("ordering wrong: %v / %v", got[0]["message"], got[1]["message"]) } } func TestServeProfileLogsLevelFilter(t *testing.T) { cfg, ring := profileTestRoot(t, []string{"alice@example.com"}) rh := NewRingHandler(ring, slog.LevelDebug) logger := slog.New(rh) logger.Debug("d") logger.Info("i") logger.Warn("w") rec := httptest.NewRecorder() ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com")) var got []map[string]any _ = json.Unmarshal(rec.Body.Bytes(), &got) if len(got) != 1 || got[0]["message"] != "w" { t.Errorf("level=warn filter failed: %+v", got) } } // TestServeProfileHTMLLayered verifies server-side conditional rendering: // non-admin HTML contains zero admin markup, admin HTML adds the admin // block, super-admin HTML adds the diagnostics block. func TestServeProfileHTMLLayered(t *testing.T) { root := t.TempDir() zf := "admins:\n - alice@example.com\n" if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(zf), 0o644); err != nil { t.Fatalf("write root .zddc: %v", err) } if err := os.MkdirAll(filepath.Join(root, "projects"), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(filepath.Join(root, "projects", ".zddc"), []byte("admins:\n - bob@example.com\n"), 0o644); err != nil { t.Fatalf("write subtree .zddc: %v", err) } zddc.InvalidateCache(root) zddc.InvalidateCache(filepath.Join(root, "projects")) cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} ring := NewLogRing(50) render := func(email string) string { rec := httptest.NewRecorder() ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/", email)) if rec.Code != http.StatusOK { t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String()) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Errorf("email=%q Content-Type = %q, want text/html", email, ct) } return rec.Body.String() } anon := render("") if !strings.Contains(anon, "Not signed in") { t.Errorf("anonymous body missing 'Not signed in'") } for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config", "diag-logs"} { if strings.Contains(anon, marker) { t.Errorf("anonymous body unexpectedly contains admin marker %q", marker) } } nonAdmin := render("carol@example.com") if !strings.Contains(nonAdmin, "carol@example.com") { t.Errorf("non-admin body missing email") } for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config"} { if strings.Contains(nonAdmin, marker) { t.Errorf("non-admin body unexpectedly contains admin marker %q", marker) } } subtree := render("bob@example.com") if !strings.Contains(subtree, "Editable .zddc files") { t.Errorf("subtree-admin body missing 'Editable .zddc files'") } if !strings.Contains(subtree, "Create new project folder") { t.Errorf("subtree-admin body missing 'Create new project folder'") } if strings.Contains(subtree, "Server config") { t.Errorf("subtree-admin body unexpectedly contains super-admin diagnostics") } super := render("alice@example.com") for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config", "diag-logs", "diag-whoami"} { if !strings.Contains(super, marker) { t.Errorf("super-admin body missing %q", marker) } } } func TestServeProfileAccessJSON(t *testing.T) { cfg, ring := profileTestRoot(t, []string{"alice@example.com"}) rec := httptest.NewRecorder() ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/access", "alice@example.com")) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } var v AccessView if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { t.Fatalf("decode: %v", err) } if v.Email != "alice@example.com" || !v.IsSuperAdmin { t.Errorf("expected super-admin alice; got %+v", v) } if v.EmailHeader != "X-Auth-Request-Email" { t.Errorf("EmailHeader = %q", v.EmailHeader) } } func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) { // .zddc exists but has no admins list — page is still reachable, // but the admin/super-admin sections are absent. cfg, ring := profileTestRoot(t, nil) if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil { t.Fatalf("write .zddc: %v", err) } zddc.InvalidateCache(cfg.Root) rec := httptest.NewRecorder() ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/", "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, "Server config") { t.Errorf("alice should not see super-admin section when no admins configured") } // Per-resource gates remain. rec = httptest.NewRecorder() ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com")) if rec.Code != http.StatusNotFound { t.Errorf("/.profile/whoami status = %d, want 404 (no admins configured)", rec.Code) } } // TestServeProfileProjectsCreate covers the happy path and the most // common rejection modes for POST /.profile/projects. func TestServeProfileProjectsCreate(t *testing.T) { root := t.TempDir() zf := "admins:\n - root@example.com\n" if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(zf), 0o644); err != nil { t.Fatalf("write root .zddc: %v", err) } zddc.InvalidateCache(root) cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} ring := NewLogRing(50) post := func(email, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") if email != "" { req = req.WithContext(context.WithValue(req.Context(), EmailKey, email)) } rec := httptest.NewRecorder() ServeProfile(cfg, ring, rec, req) return rec } // Happy path: super-admin creates /alpha with no .zddc body. rec := post("root@example.com", `{"parent":"/", "name":"alpha"}`) if rec.Code != http.StatusCreated { t.Fatalf("happy path status=%d body=%s", rec.Code, rec.Body.String()) } if _, err := os.Stat(filepath.Join(root, "alpha")); err != nil { t.Errorf("alpha dir not created on disk: %v", err) } if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err == nil { t.Errorf(".zddc should NOT be auto-written when no fields supplied") } // Body with a title also writes a .zddc. rec = post("root@example.com", `{"parent":"/", "name":"beta", "title":"Beta site"}`) if rec.Code != http.StatusCreated { t.Fatalf("create-with-title status=%d body=%s", rec.Code, rec.Body.String()) } if _, err := os.Stat(filepath.Join(root, "beta", ".zddc")); err != nil { t.Errorf(".zddc should be written when title supplied: %v", err) } // Conflict on existing dir. rec = post("root@example.com", `{"parent":"/", "name":"alpha"}`) if rec.Code != http.StatusConflict { t.Errorf("duplicate create status=%d, want 409 (body=%s)", rec.Code, rec.Body.String()) } // Bad name. rec = post("root@example.com", `{"parent":"/", "name":".hidden"}`) if rec.Code != http.StatusBadRequest { t.Errorf("bad name status=%d, want 400 (body=%s)", rec.Code, rec.Body.String()) } rec = post("root@example.com", `{"parent":"/", "name":"a/b"}`) if rec.Code != http.StatusBadRequest { t.Errorf("path-separator name status=%d, want 400", rec.Code) } // Reserved-prefix parent. rec = post("root@example.com", `{"parent":"/.foo", "name":"x"}`) if rec.Code != http.StatusNotFound { t.Errorf("reserved-prefix parent status=%d, want 404", rec.Code) } // Non-existent parent. rec = post("root@example.com", `{"parent":"/does-not-exist", "name":"x"}`) if rec.Code != http.StatusBadRequest { t.Errorf("missing-parent status=%d, want 400", rec.Code) } // Anonymous and non-admin: 404 (no admin scope anywhere). rec = post("", `{"parent":"/", "name":"gamma"}`) if rec.Code != http.StatusNotFound { t.Errorf("anonymous status=%d, want 404", rec.Code) } rec = post("mallory@example.com", `{"parent":"/", "name":"gamma"}`) if rec.Code != http.StatusNotFound { t.Errorf("non-admin status=%d, want 404", rec.Code) } // Method other than POST is 405. req := httptest.NewRequest(http.MethodGet, "/.profile/projects", nil) req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com")) rec = httptest.NewRecorder() ServeProfile(cfg, ring, rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("GET /.profile/projects status=%d, want 405", rec.Code) } } // TestServeProfileProjectsCreateValidatesZddc covers ACL/admin pattern // validation: an invalid glob in the body must roll the directory back. func TestServeProfileProjectsCreateValidatesZddc(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 root .zddc: %v", err) } zddc.InvalidateCache(root) cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} body := `{"parent":"/", "name":"badproject", "acl":{"allow":["bad@@glob"], "deny":[]}}` req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com")) rec := httptest.NewRecorder() ServeProfile(cfg, NewLogRing(50), rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status=%d, want 400; body=%s", rec.Code, rec.Body.String()) } if _, err := os.Stat(filepath.Join(root, "badproject")); err == nil { t.Errorf("dir should not exist after validation rejection") } } // TestSubtreeAdminCanCreateInScope: a subtree admin (alice on /projects) // can create /projects/sub but not /other. func TestSubtreeAdminCanCreateInScope(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 root .zddc: %v", err) } if err := os.MkdirAll(filepath.Join(root, "projects"), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(filepath.Join(root, "projects", ".zddc"), []byte("admins:\n - alice@example.com\n"), 0o644); err != nil { t.Fatalf("write subtree .zddc: %v", err) } if err := os.MkdirAll(filepath.Join(root, "other"), 0o755); err != nil { t.Fatalf("mkdir other: %v", err) } zddc.InvalidateCache(root) zddc.InvalidateCache(filepath.Join(root, "projects")) cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} post := func(parent, name string) int { body := `{"parent":"` + parent + `", "name":"` + name + `"}` req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com")) rec := httptest.NewRecorder() ServeProfile(cfg, NewLogRing(50), rec, req) return rec.Code } if code := post("/projects", "sub"); code != http.StatusCreated { t.Errorf("alice creating /projects/sub: status=%d, want 201", code) } if code := post("/other", "sub"); code != http.StatusNotFound { t.Errorf("alice creating /other/sub: status=%d, want 404 (no scope)", code) } } // TestAdminPathHardCut verifies the legacy /.admin prefix is not handled // by the server — every /.admin/* falls through to the dispatcher's normal // path resolution which 404s on the dot-prefix guard. func TestAdminPathHardCut(t *testing.T) { cfg, ring := profileTestRoot(t, []string{"alice@example.com"}) for _, p := range []string{"/.admin/", "/.admin/whoami", "/.admin/zddc/edit?path=/"} { rec := httptest.NewRecorder() req := requestWithEmail(http.MethodGet, p, "alice@example.com") // Calling ServeProfile directly with /.admin path: it should not match // the /.profile prefix and so return 404. (The real-world path is // dispatch() routing — covered in main_test.go.) ServeProfile(cfg, ring, rec, req) if rec.Code != http.StatusNotFound { t.Errorf("path=%q status=%d, want 404 (hard-cut)", p, rec.Code) } } }