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>
98 lines
3.4 KiB
Go
98 lines
3.4 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
|
)
|
|
|
|
// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
|
|
// rejects requests whose URL contains a dot-prefixed segment (other than
|
|
// the recognized virtual prefixes .archive and /.profile handled separately).
|
|
//
|
|
// The guard exists so the in-image dev-shell can keep persistent state
|
|
// (settings, source clones, Go module cache) under /srv/.devshell on the
|
|
// same Azure Files PVC as served data without ever exposing those files
|
|
// via direct HTTP fetch.
|
|
func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// Realistic shape: a project dir, a hidden top-level dir, and a hidden
|
|
// sibling of a normal file inside the project.
|
|
mustMkdir(t, filepath.Join(root, "Project-A"))
|
|
mustWrite(t, filepath.Join(root, "Project-A", "doc.txt"), "ok")
|
|
mustMkdir(t, filepath.Join(root, ".devshell"))
|
|
mustMkdir(t, filepath.Join(root, ".devshell", "coder"))
|
|
mustWrite(t, filepath.Join(root, ".devshell", "coder", "settings.json"), "secret")
|
|
mustMkdir(t, filepath.Join(root, "Project-A", ".internal"))
|
|
mustWrite(t, filepath.Join(root, "Project-A", ".internal", "notes.md"), "secret")
|
|
|
|
idx, err := archive.BuildIndex(root)
|
|
if err != nil {
|
|
t.Fatalf("BuildIndex: %v", err)
|
|
}
|
|
|
|
cfg := config.Config{
|
|
Root: root,
|
|
IndexPath: ".archive",
|
|
EmailHeader: "X-Auth-Request-Email",
|
|
}
|
|
ring := handler.NewLogRing(10)
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
wantStatus int
|
|
}{
|
|
// Hidden top-level dir — every shape blocked.
|
|
{"hidden top dir", "/.devshell/", http.StatusNotFound},
|
|
{"hidden top dir nested", "/.devshell/coder/settings.json", http.StatusNotFound},
|
|
|
|
// Hidden segment under a real project dir — also blocked.
|
|
{"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound},
|
|
|
|
// Sanity: recognized virtual prefixes are NOT blocked. .archive falls
|
|
// through to its own handler (which 404s on missing tracking number);
|
|
// .profile is handled by ServeProfile and the page itself is public.
|
|
// /.admin no longer exists — it is hard-cut and falls through to the
|
|
// dot-prefix guard, which 404s.
|
|
{".archive prefix passes guard", "/.archive/UNKNOWN", http.StatusNotFound}, // unknown tracking → 404 from archive handler
|
|
{".profile not blocked by guard", "/.profile/", http.StatusOK}, // public page renders for anonymous
|
|
{".admin hard-cut → dot-prefix guard", "/.admin/whoami", http.StatusNotFound},
|
|
|
|
// Normal files unaffected.
|
|
{"plain file", "/Project-A/doc.txt", http.StatusOK},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, rec, req)
|
|
if rec.Code != tc.wantStatus {
|
|
t.Errorf("path=%q status=%d want=%d body=%q",
|
|
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func mustMkdir(t *testing.T, path string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func mustWrite(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|