Wires up live alpha-dev iteration on bitnest. With this change a
`.zddc apps: <tool>: <path>` entry overrides the embedded copy for any
of the eight tools, not just five.
Two coupled fixes:
1. zddc.AppNames had a five-entry list (archive/transmittal/
classifier/mdedit/landing) — predating browse/form/tables.
ResolveWithOverride's `if !IsKnownApp(app)` gate silently rejected
those three before ever looking at the cascade, falling back to
embedded with an "unknown app" error.
2. handler.ServeDirectory hard-coded `apps.EmbeddedBytes("browse")`
for the HTML directory-listing fallback, bypassing the apps
subsystem entirely. Now takes an optional *apps.Server and
delegates to appsSrv.Serve(w, r, "browse", chain, absDir) when
wired, so the cascade is honored at bare directory URLs too
(the most common way browse gets surfaced).
Both call sites in main.go and the test signatures in
directory_test.go updated. ValidateFile error message now lists all
eight known apps.
Verified end-to-end on bitnest with a root .zddc apps cascade
pointing at /srv/.zddc.d/source/<tool>/dist/<file>: every `./build`
on the host is now immediately visible after a hard refresh. Iteration
loop is `./build` (or `sh tool/build.sh`) then reload — no container
restart needed, since the apps subsystem reads the path source on
each request.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
236 lines
8.4 KiB
Go
236 lines
8.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// TestServeDirectoryRootIsPublic asserts that the landing page (the root
|
|
// directory listing) is reachable by anyone, including anonymous callers
|
|
// whose email is empty AND whose access would be denied by a restrictive
|
|
// root .zddc. Per-project filtering inside fs.ListDirectory still hides
|
|
// directories the caller can't reach (separately verified below).
|
|
//
|
|
// The behavior was changed when "Everyone needs to have access to the
|
|
// landing page" became the explicit requirement; this test is the
|
|
// regression guard.
|
|
func TestServeDirectoryRootIsPublic(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// Restrictive root .zddc — only admin@example.com is allowed by ACL,
|
|
// nothing else. A user without that email would have been 403'd before
|
|
// the bypass.
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
[]byte("admins:\n - admin@example.com\nacl:\n allow:\n - admin@example.com\n"),
|
|
0o644); err != nil {
|
|
t.Fatalf("write root .zddc: %v", err)
|
|
}
|
|
|
|
// One project visible to everyone, one only to admin.
|
|
for _, name := range []string{"PublicProj", "PrivateProj"} {
|
|
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, "PublicProj", ".zddc"),
|
|
[]byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
|
|
t.Fatalf("write PublicProj .zddc: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "PrivateProj", ".zddc"),
|
|
[]byte("acl:\n allow: [admin@example.com]\n"), 0o644); err != nil {
|
|
t.Fatalf("write PrivateProj .zddc: %v", err)
|
|
}
|
|
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
t.Run("anonymous JSON GET / does not 403", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Accept", "application/json")
|
|
// Anonymous: empty email in context.
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, ""))
|
|
rec := httptest.NewRecorder()
|
|
ServeDirectory(cfg, nil, rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (root is public); body = %s",
|
|
rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("anonymous JSON GET / hides private projects", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Accept", "application/json")
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, ""))
|
|
rec := httptest.NewRecorder()
|
|
ServeDirectory(cfg, nil, rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var entries []map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &entries); err != nil {
|
|
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
|
|
}
|
|
|
|
names := map[string]bool{}
|
|
for _, e := range entries {
|
|
if n, ok := e["name"].(string); ok {
|
|
names[n] = true
|
|
}
|
|
}
|
|
if !names["PublicProj/"] {
|
|
t.Errorf("PublicProj missing from anonymous listing: %v", names)
|
|
}
|
|
if names["PrivateProj/"] {
|
|
t.Errorf("PrivateProj leaked to anonymous listing: %v", names)
|
|
}
|
|
})
|
|
|
|
t.Run("admin JSON GET / sees both projects", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Accept", "application/json")
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "admin@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeDirectory(cfg, nil, rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("admin status = %d, want 200", rec.Code)
|
|
}
|
|
|
|
var entries []map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &entries); err != nil {
|
|
t.Fatalf("invalid JSON: %v", err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Errorf("admin should see both projects; got %d", len(entries))
|
|
}
|
|
})
|
|
|
|
t.Run("anonymous still gets 403 on private subdirectory", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/PrivateProj/", nil)
|
|
req.Header.Set("Accept", "application/json")
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, ""))
|
|
rec := httptest.NewRecorder()
|
|
ServeDirectory(cfg, nil, rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("private subdir for anonymous: status = %d, want 403", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestServeDirectoryRedirectsTableRowsDir asserts that an HTML GET on a
|
|
// directory containing a table.yaml bounces to that dir's table.html URL
|
|
// (in-dir convention: /<dir>/table.html serves the table view, the row
|
|
// YAMLs are siblings of table.yaml). JSON GETs fall through to the
|
|
// listing so the table client can still enumerate row files.
|
|
func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) {
|
|
root := t.TempDir()
|
|
mdlDir := filepath.Join(root, "Working", "MDL")
|
|
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"),
|
|
[]byte(sampleTableSpec), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(mdlDir, "form.yaml"),
|
|
[]byte(sampleRowFormSpec), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "Working", ".zddc"), []byte(`acl:
|
|
permissions:
|
|
"*@example.com": rwcda
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
t.Run("HTML GET on dir with table.yaml redirects to table.html", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/Working/MDL/", nil)
|
|
req.Header.Set("Accept", "text/html")
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeDirectory(cfg, nil, rec, req)
|
|
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got, want := rec.Header().Get("Location"), "/Working/MDL/table.html"; got != want {
|
|
t.Errorf("Location = %q, want %q", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("JSON GET on dir with table.yaml falls through to listing", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/Working/MDL/", nil)
|
|
req.Header.Set("Accept", "application/json")
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeDirectory(cfg, nil, rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
})
|
|
|
|
t.Run("HTML GET on plain dir is not redirected", func(t *testing.T) {
|
|
// Sibling dir without a table.yaml — no redirect.
|
|
if err := os.MkdirAll(filepath.Join(root, "Working", "Other"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/Working/Other/", nil)
|
|
req.Header.Set("Accept", "text/html")
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeDirectory(cfg, nil, rec, req)
|
|
|
|
if rec.Code == http.StatusFound {
|
|
t.Fatalf("got 302 to %q for non-table dir", rec.Header().Get("Location"))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestServeDirectoryRedirectsDefaultMdl covers the default-MDL fallback:
|
|
// archive/<party>/mdl/ with no on-disk table.yaml still redirects
|
|
// to mdl/table.html (the table handler serves embedded defaults).
|
|
func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl")
|
|
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Project/archive/Acme/mdl/", nil)
|
|
req.Header.Set("Accept", "text/html")
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeDirectory(cfg, nil, rec, req)
|
|
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got, want := rec.Header().Get("Location"), "/Project/archive/Acme/mdl/table.html"; got != want {
|
|
t.Errorf("Location = %q, want %q", got, want)
|
|
}
|
|
}
|