fix(zddc-server): canonical-folder fallback also at the dispatcher
The previous fix in fs.ListDirectory was insufficient — main.go's dispatcher calls os.Stat(absPath) before reaching ServeDirectory, and 404s on the missing path before the listing code ever runs. Symptom: GET <project>/working/ on a fresh project still returned "Not Found" despite the read-side fallback being committed. Add the same fallback at the dispatcher level: when os.Stat returns NotExist AND the URL ends with "/" AND the path matches IsProjectRootFolder, fall through to ServeDirectory rather than 404. ServeDirectory's ACL check + ListDirectory's empty-listing behavior take it from there. Separately, fs.ListDirectory now initializes its result slice to make([]listing.FileInfo, 0) instead of `var result []listing.FileInfo`, so the JSON encoder emits "[]" rather than "null" for empty listings — clients (browse, archive) expect an array and choke on null. New test TestDispatchEmptyCanonicalProjectFolders covers the four canonical names (archive/working/staging/reviewing) on a project where none of them exist on disk yet, plus the negative case (a non-canonical missing path still 404s). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1d12cfe804
commit
41e6576111
3 changed files with 83 additions and 1 deletions
|
|
@ -841,6 +841,21 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
}
|
||||
}
|
||||
}
|
||||
// Empty-listing fallback for canonical project-root folders.
|
||||
// <project>/{archive,working,staging,reviewing}/ that don't
|
||||
// yet exist on disk should render as a usable empty
|
||||
// directory view rather than 404, so the stage-strip nav
|
||||
// links land on a real page on a fresh project. The
|
||||
// matching read-side fallback in fs.ListDirectory returns
|
||||
// 200 + [] for the same paths; we fall through to
|
||||
// ServeDirectory which goes through that path and applies
|
||||
// ACL via the parent project's .zddc cascade.
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||
strings.HasSuffix(urlPath, "/") &&
|
||||
zddc.IsProjectRootFolder(strings.TrimPrefix(strings.TrimSuffix(urlPath, "/"), "/")) {
|
||||
handler.ServeDirectory(cfg, w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
|
|
|
|||
|
|
@ -466,6 +466,71 @@ func TestDispatchSlashRouting(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Canonical project-root folders (archive/working/staging/reviewing)
|
||||
// that don't yet exist on disk must render as 200 + empty listing
|
||||
// rather than 404, so the stage-strip nav lands on a usable view on a
|
||||
// fresh project. Mirror of fs.ListDirectory's read-side fallback at
|
||||
// the dispatcher level — without this, os.Stat 404s before
|
||||
// ServeDirectory ever runs.
|
||||
func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"*\": rwcda\n")
|
||||
// Project exists, but NO subdirs (no archive/, working/, staging/,
|
||||
// reviewing/). Fresh-project state.
|
||||
mustMkdir(t, filepath.Join(root, "Project"))
|
||||
|
||||
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)
|
||||
appsSrv, err := setupApps(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("setupApps: %v", err)
|
||||
}
|
||||
|
||||
// JSON request → 200 + JSON array (not null, not 404).
|
||||
// Virtual user-home injection at <project>/working/ depends on a
|
||||
// context-bound email; this test calls dispatch directly without
|
||||
// the ACL middleware that sets that context, so email is "" here
|
||||
// and working/ also returns []. virtualUserHomeEntry's email
|
||||
// branch is covered separately by tests in internal/fs/tree_test.go.
|
||||
for _, stage := range []string{"archive", "working", "staging", "reviewing"} {
|
||||
t.Run("json/"+stage, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/Project/"+stage+"/", nil)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := strings.TrimSpace(rec.Body.String())
|
||||
if body != "[]" {
|
||||
t.Errorf("%s/ body=%q, want %q", stage, body, "[]")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Non-canonical missing folder still 404s (the fallback is
|
||||
// scoped to the four canonical names, not a blanket "missing →
|
||||
// empty" rule).
|
||||
t.Run("non-canonical missing → 404", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/Project/random-folder/", nil)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status=%d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on
|
||||
// any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file
|
||||
// API's write path, so a write to an archive URL never silently mutates
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
}
|
||||
}
|
||||
|
||||
var result []listing.FileInfo
|
||||
// Empty (not nil) so the JSON encoder emits [] rather than null
|
||||
// when no entries match — clients (browse, archive) expect an array.
|
||||
result := make([]listing.FileInfo, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
|
|
|
|||
Loading…
Reference in a new issue