Replaces the super-admin-only /.admin/ surface with a public-by-default /.profile/ page that layers admin tools server-side based on the caller's effective access: - Universal (everyone, anonymous included): identity card, effective access summary, theme picker, localStorage utilities (export / import / clear, landing-presets viewer). - Subtree admins additionally see: editable .zddc files list (linking to the existing form-based editor) and a "Create new project folder" form. - Super-admins additionally see: server config, log viewer, whoami headers (the old /.admin/ JSON endpoints, repointed under /.profile/). Project creation is gated on CanEditZddc(newDir) — the same strict- ancestor rule that already governs .zddc writes — so no new authority concept is introduced. ValidateProjectName mirrors the existing reserved-prefix policy (no leading '.' or '_', no path separators). /.admin/* is hard-cut: no redirect shim. Old URLs fall through to the existing dot-prefix guard and 404. Custom CSS file rename: prefer <root>/.profile.css, fall back to legacy <root>/.admin.css. Per-resource 404 leakage gates preserved on whoami / config / logs / zddc / projects so non-admin callers cannot detect the existence of admin-only sub-resources. Tree-wide gofmt -w applied as a side-effect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 lines
3.6 KiB
Go
117 lines
3.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
)
|
|
|
|
// TestServeProjectListFiltersHiddenAndScaffolding asserts the project list
|
|
// excludes both '.'-prefixed entries (the long-standing rule, e.g. .devshell)
|
|
// AND '_'-prefixed entries (operator scaffolding like install.zip's
|
|
// _template/ that's reachable by direct URL but should not clutter the
|
|
// project picker).
|
|
func TestServeProjectListFiltersHiddenAndScaffolding(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
for _, name := range []string{
|
|
"Project-A",
|
|
"Project-B",
|
|
".devshell", // dot-prefixed dir — must be excluded
|
|
"_template", // underscore scaffolding — must be excluded
|
|
"_archive",
|
|
} {
|
|
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// A loose .zddc that allows everyone, so ACL doesn't interfere with
|
|
// what we're actually testing (the prefix filter).
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
[]byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
|
|
t.Fatalf("write .zddc: %v", err)
|
|
}
|
|
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
|
|
ServeProjectList(cfg, rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var got []map[string]string
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
|
|
}
|
|
|
|
want := map[string]bool{"Project-A": true, "Project-B": true}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("got %d projects (%+v), want %d (%v)", len(got), got, len(want), want)
|
|
}
|
|
for _, p := range got {
|
|
if !want[p["name"]] {
|
|
t.Errorf("unexpected project in list: %q", p["name"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestServeProjectListIncludesTitleFromPerProjectZddc verifies a project's own
|
|
// .zddc `title:` field surfaces in the JSON response; projects without it (or
|
|
// without any .zddc) come back with an empty/absent title.
|
|
func TestServeProjectListIncludesTitleFromPerProjectZddc(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
for _, name := range []string{"176109", "197072"} {
|
|
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
[]byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
|
|
t.Fatalf("write root .zddc: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "176109", ".zddc"),
|
|
[]byte("title: \"Greenfield Substation\"\n"), 0o644); err != nil {
|
|
t.Fatalf("write project .zddc: %v", err)
|
|
}
|
|
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
|
|
ServeProjectList(cfg, rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var got []ProjectInfo
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
|
|
}
|
|
|
|
titles := map[string]string{}
|
|
for _, p := range got {
|
|
titles[p.Name] = p.Title
|
|
}
|
|
if titles["176109"] != "Greenfield Substation" {
|
|
t.Errorf("176109 title = %q, want %q", titles["176109"], "Greenfield Substation")
|
|
}
|
|
if titles["197072"] != "" {
|
|
t.Errorf("197072 title = %q, want empty", titles["197072"])
|
|
}
|
|
}
|