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)
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
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
|
// TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on
|
||||||
// any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file
|
// 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
|
// 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 {
|
for _, entry := range entries {
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue