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>
195 lines
5 KiB
Go
195 lines
5 KiB
Go
package archive
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"testing"
|
|
)
|
|
|
|
func mkTransmittal(t *testing.T, fsRoot, folderName string, files ...string) {
|
|
t.Helper()
|
|
dir := filepath.Join(fsRoot, folderName)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", dir, err)
|
|
}
|
|
for _, f := range files {
|
|
path := filepath.Join(dir, f)
|
|
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCompareRevisions_DraftOrdering(t *testing.T) {
|
|
cases := []struct {
|
|
a, b string
|
|
want int // sign only
|
|
}{
|
|
{"~A", "A", -1},
|
|
{"~A", "~B", -1},
|
|
{"A", "B", -1},
|
|
{"~A", "~A", 0},
|
|
{"A", "~A", 1},
|
|
}
|
|
for _, c := range cases {
|
|
got := compareRevisions(c.a, c.b)
|
|
var sign int
|
|
if got < 0 {
|
|
sign = -1
|
|
} else if got > 0 {
|
|
sign = 1
|
|
}
|
|
if sign != c.want {
|
|
t.Errorf("compareRevisions(%q, %q) sign = %d, want %d", c.a, c.b, sign, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIndexAndResolve_DraftOnly(t *testing.T) {
|
|
root := t.TempDir()
|
|
mkTransmittal(t, root, "2025-01-01_T1 (IFR) - Title",
|
|
"123_~A (IFR) - Title.pdf",
|
|
)
|
|
|
|
idx, err := BuildIndex(root)
|
|
if err != nil {
|
|
t.Fatalf("BuildIndex: %v", err)
|
|
}
|
|
|
|
te, ok := idx.ByTracking["123"]
|
|
if !ok {
|
|
t.Fatalf("tracking 123 not indexed")
|
|
}
|
|
if te.HighestBaseRev != "~A" {
|
|
t.Errorf("HighestBaseRev = %q, want ~A", te.HighestBaseRev)
|
|
}
|
|
|
|
if _, ok := Resolve(idx, "123.html"); !ok {
|
|
t.Errorf("Resolve(123.html) failed")
|
|
}
|
|
if _, ok := Resolve(idx, "123_~A.html"); !ok {
|
|
t.Errorf("Resolve(123_~A.html) failed")
|
|
}
|
|
}
|
|
|
|
func TestIndexAndResolve_DraftWithModifier(t *testing.T) {
|
|
root := t.TempDir()
|
|
mkTransmittal(t, root, "2025-01-01_T1 (IFR) - Title",
|
|
"123_~A (IFR) - Title.pdf",
|
|
)
|
|
mkTransmittal(t, root, "2025-02-01_T2 (RTN) - Comments",
|
|
"123_~A+C1 (RTN) - Comments.pdf",
|
|
)
|
|
|
|
idx, _ := BuildIndex(root)
|
|
if _, ok := Resolve(idx, "123_~A+C1.html"); !ok {
|
|
t.Errorf("Resolve(123_~A+C1.html) failed")
|
|
}
|
|
}
|
|
|
|
// "First chronologically found version of the latest rev": when the same rev
|
|
// appears in two transmittals, the earlier date's copy wins.
|
|
func TestRecordFile_FirstChronologicalWins(t *testing.T) {
|
|
root := t.TempDir()
|
|
mkTransmittal(t, root, "2025-03-01_Late (IFR) - Title",
|
|
"123_A (IFR) - Title.pdf",
|
|
)
|
|
mkTransmittal(t, root, "2025-01-01_Early (IFR) - Title",
|
|
"123_A (IFR) - Title.pdf",
|
|
)
|
|
|
|
idx, _ := BuildIndex(root)
|
|
target, ok := Resolve(idx, "123_A.html")
|
|
if !ok {
|
|
t.Fatalf("Resolve(123_A.html) failed")
|
|
}
|
|
if !contains(target, "2025-01-01_Early") {
|
|
t.Errorf("got %q, want path under 2025-01-01_Early/", target)
|
|
}
|
|
}
|
|
|
|
// AllEntries: every (tracking) gets <tracking>.html (highest) AND a
|
|
// <tracking>_<rev>.html for every base revision present.
|
|
func TestAllEntries_PerRevisionSurfaced(t *testing.T) {
|
|
root := t.TempDir()
|
|
mkTransmittal(t, root, "2025-01-01_T1 (IFR) - Title",
|
|
"123_~A (IFR) - Title.pdf",
|
|
)
|
|
mkTransmittal(t, root, "2025-03-01_T3 (IFC) - Title",
|
|
"123_A (IFC) - Title.pdf",
|
|
"456_0 (IFR) - Other.pdf",
|
|
)
|
|
|
|
idx, _ := BuildIndex(root)
|
|
entries := idx.AllEntries()
|
|
|
|
got := make(map[string]string, len(entries))
|
|
for _, e := range entries {
|
|
got[e.URLName] = e.TargetPath
|
|
}
|
|
|
|
// Highest-rev shortcut + each per-rev redirect should be present.
|
|
wantNames := []string{
|
|
"123.html", // highest of 123 → A
|
|
"123_A.html", // explicit A
|
|
"123_~A.html", // explicit draft
|
|
"456.html", // highest of 456 → 0
|
|
"456_0.html", // explicit 0
|
|
}
|
|
for _, n := range wantNames {
|
|
if _, ok := got[n]; !ok {
|
|
t.Errorf("missing entry %q; got %v", n, sortedKeys(got))
|
|
}
|
|
}
|
|
|
|
// 123.html should resolve to the same path as 123_A.html (both point to
|
|
// the highest-rev's first-chronological copy).
|
|
if got["123.html"] != got["123_A.html"] {
|
|
t.Errorf("123.html (%q) != 123_A.html (%q); should both resolve to highest",
|
|
got["123.html"], got["123_A.html"])
|
|
}
|
|
|
|
// Sort: <tracking>.html sorts before <tracking>_*.html (because '.'<'_').
|
|
for i := 1; i < len(entries); i++ {
|
|
if entries[i-1].URLName > entries[i].URLName {
|
|
t.Errorf("AllEntries not sorted: %q before %q", entries[i-1].URLName, entries[i].URLName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Modifier-only files (no base) don't get a <tracking>.html or
|
|
// <tracking>_<rev>.html entry — the redirect would have nowhere to go since
|
|
// re.BasePath is empty. They remain reachable via <tracking>_<rev>+<mod>.html
|
|
// through the resolver but are not surfaced in the listing.
|
|
func TestAllEntries_ModifierOnlyNoBaseSkipped(t *testing.T) {
|
|
root := t.TempDir()
|
|
mkTransmittal(t, root, "2025-02-01_T2 (RTN) - Comments",
|
|
"123_~A+C1 (RTN) - Comments.pdf",
|
|
)
|
|
|
|
idx, _ := BuildIndex(root)
|
|
for _, e := range idx.AllEntries() {
|
|
if e.URLName == "123.html" || e.URLName == "123_~A.html" {
|
|
t.Errorf("unexpected entry %q (no base file exists)", e.URLName)
|
|
}
|
|
}
|
|
}
|
|
|
|
func contains(s, sub string) bool {
|
|
for i := 0; i+len(sub) <= len(s); i++ {
|
|
if s[i:i+len(sub)] == sub {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func sortedKeys(m map[string]string) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|