The site-root .zddc.zip bundle was existence-hidden (404) over HTTP for
everyone. Now an active (elevated) admin over its directory can browse it
in the file tree like any other zip: GET /.zddc.zip/ lists members, GET
/.zddc.zip/<member> extracts one, and a bare GET downloads it. Everyone
else — including the same admin un-elevated — still gets 404 for every URL
shape, which additionally closes a prior by-name member read (the old gate
only 404'd the bundle base, so /.zddc.zip/<member> leaked to any reader of
the root).
The dispatch gate now keys off the bundle segment anywhere in the path and
requires activeAdminForBundle (mirrors ActiveAdminForSidecar). The listing
(fs.ListDirectory) surfaces the .zddc.d reserve and .zddc.zip bundle only to
an active admin, so non-admins don't even see the names under ?hidden=1.
Client needs no change: splitExtension('.zddc.zip').extension == 'zip', so
browse already renders it as a navigable archive (tree.js isZip). Internal
apps.Bundle FS resolution never goes through dispatch, so it's unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1195 lines
46 KiB
Go
1195 lines
46 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// TestDispatchReservesZddcD asserts the dispatch() gate that reserves the one
|
|
// bookkeeping namespace, .zddc.d/. Non-admin requests to .zddc.d/ are 404'd at
|
|
// every depth (existence-hidden token store), while every OTHER dot-/underscore-
|
|
// prefixed path is ordinary ACL-governed content and is served like any normal
|
|
// file (a leading dot only hides it from listings). The recognized virtual
|
|
// prefixes (.archive, /.profile) are routed before the gate.
|
|
func TestDispatchReservesZddcD(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// Realistic shape: a project dir, a reserved .zddc.d/ token store, 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, ".zddc.d", "tokens"))
|
|
mustWrite(t, filepath.Join(root, ".zddc.d", "tokens", "abc123"), "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
|
|
}{
|
|
// Reserved .zddc.d/ bookkeeping — every shape 404'd for a non-admin
|
|
// (no ACLMiddleware here ⇒ unelevated principal).
|
|
{"reserved .zddc.d dir", "/.zddc.d/", http.StatusNotFound},
|
|
{"reserved .zddc.d token", "/.zddc.d/tokens/abc123", http.StatusNotFound},
|
|
// Case variants must ALSO be reserved: on a case-insensitive root
|
|
// (SMB/CIFS/Azure Files) `.ZDDC.D` resolves to the same dir, so the
|
|
// gate folds case (HasReservedSidecar uses EqualFold).
|
|
{"reserved .ZDDC.D upper", "/.ZDDC.D/tokens/abc123", http.StatusNotFound},
|
|
{"reserved .Zddc.D mixed", "/Project-A/.Zddc.D/history/x", http.StatusNotFound},
|
|
// The reserve's own .zddc cascade is hidden too: the sidecar gate runs
|
|
// before the raw .zddc view, so this never reaches ServeZddcFile.
|
|
{"reserved .zddc.d/.zddc cascade", "/Project-A/.zddc.d/.zddc", http.StatusNotFound},
|
|
|
|
// Other dot-prefixed content is no longer blocked by dispatch — it's
|
|
// ACL-governed like any file. This harness passes a nil decider, so the
|
|
// hidden file serves (the cascade is what gates it in production).
|
|
{"hidden dot content served", "/Project-A/.internal/notes.md", http.StatusOK},
|
|
|
|
// Sanity: recognized virtual prefixes are routed before the gate.
|
|
// .archive falls through to its own handler (404 on missing tracking);
|
|
// .profile is public. /.admin was hard-cut and now simply 404s as a
|
|
// not-found file (no dot-prefix guard left to reject it).
|
|
{".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 → not found", "/.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, nil, nil, 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())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDispatchAppsResolution drives local tool-HTML resolution through
|
|
// dispatch(): the site .zddc.zip member overrides the embedded default, the
|
|
// embedded default is served when no bundle member exists, GET / serves
|
|
// landing, the bundle itself is 404 over HTTP, and folder-availability rules
|
|
// still gate which tools are served where.
|
|
func TestDispatchAppsResolution(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// Allow-all ACL so the test doesn't need email headers.
|
|
zf := zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}}}
|
|
if err := zddc.WriteFile(root, zf); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
// Site config bundle overriding archive.html.
|
|
writeRootBundle(t, root, map[string]string{"archive.html": "<!doctype html>BUNDLE archive"})
|
|
// Folder-convention dir so classifier availability passes below.
|
|
mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))
|
|
|
|
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)
|
|
}
|
|
|
|
// GET /archive.html → served from the bundle member (overrides embedded).
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "BUNDLE archive") {
|
|
t.Fatalf("/archive.html: status=%d body=%s (want bundle override)", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Header().Get("X-ZDDC-Source") != "bundle:archive.html" {
|
|
t.Errorf("X-ZDDC-Source=%q, want bundle:archive.html", rec.Header().Get("X-ZDDC-Source"))
|
|
}
|
|
|
|
// GET / → landing (no bundle member → embedded).
|
|
rec3 := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if rec3.Code != http.StatusOK {
|
|
t.Errorf("GET /: status=%d", rec3.Code)
|
|
}
|
|
|
|
// The site bundle is config, not content: a direct GET is 404 for everyone.
|
|
rec4 := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/.zddc.zip", nil))
|
|
if rec4.Code != http.StatusNotFound {
|
|
t.Errorf("GET /.zddc.zip: status=%d, want 404", rec4.Code)
|
|
}
|
|
|
|
// Folder availability rules: classifier should NOT be served at root
|
|
// (root has no per-party working/staging/incoming ancestor), but
|
|
// SHOULD work at /Project-A/archive/<party>/working/ where the per-
|
|
// party cascade declares classifier available.
|
|
rec5 := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
|
|
if rec5.Code != http.StatusNotFound {
|
|
t.Errorf("/classifier.html at root: status=%d, want 404 (not in per-party working/staging/incoming)", rec5.Code)
|
|
}
|
|
rec6 := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/working/Acme/classifier.html", nil))
|
|
if rec6.Code != http.StatusOK {
|
|
t.Errorf("/Project-A/working/Acme/classifier.html: status=%d, want 200", rec6.Code)
|
|
}
|
|
}
|
|
|
|
// TestDispatchRootAppShellPublicButDataGated locks in the root-path tool-shell
|
|
// bypass: a non-root, un-elevated user (no read grant anywhere at/under root)
|
|
// can GET /archive.html — the shell is a static app, served like the landing
|
|
// page — but the underlying data stays ACL-gated, so the same user is still
|
|
// Forbidden from reading a project directory they have no grant on. This is
|
|
// what makes the root-level multi-project archive (/archive.html?projects=A,B)
|
|
// usable by per-project-scoped users without admin elevation.
|
|
func TestDispatchRootAppShellPublicButDataGated(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// Root grants only alice; eve has no read grant at root or anywhere under it.
|
|
zf := zddc.ZddcFile{
|
|
ACL: zddc.ACLRules{Permissions: map[string]string{"alice@example.com": "rwcd"}},
|
|
}
|
|
if err := zddc.WriteFile(root, zf); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
mustMkdir(t, filepath.Join(root, "Project-A"))
|
|
|
|
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)
|
|
}
|
|
|
|
// eve: no grant anywhere, NOT elevated (ElevatedKey unset → false).
|
|
eveReq := func(method, path string) *http.Request {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
return req.WithContext(context.WithValue(req.Context(), handler.EmailKey, "eve@example.com"))
|
|
}
|
|
|
|
// The shell at root is served regardless of a root read grant.
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec, eveReq(http.MethodGet, "/archive.html"))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /archive.html as non-root user: status=%d, want 200 (root tool shell is public); body=%s",
|
|
rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// ...but data is still ACL-gated: eve cannot read a project she has no grant on.
|
|
rec2 := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec2, eveReq(http.MethodGet, "/Project-A/"))
|
|
if rec2.Code != http.StatusForbidden {
|
|
t.Errorf("GET /Project-A/ as non-root user: status=%d, want 403 (data stays ACL-gated)", rec2.Code)
|
|
}
|
|
}
|
|
|
|
// TestDispatchRoutesWritesToFileAPI verifies dispatch sends PUT/DELETE/POST
|
|
// to the file API rather than to the read pipeline.
|
|
func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"*@example.com\": rwcd\n")
|
|
mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))
|
|
// Register the party (party_source: ssr).
|
|
mustMkdir(t, filepath.Join(root, "Project-A", "ssr"))
|
|
mustWrite(t, filepath.Join(root, "Project-A", "ssr", "Acme.yaml"), "kind: SSR\n")
|
|
|
|
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",
|
|
MaxWriteBytes: 1 << 20,
|
|
}
|
|
ring := handler.NewLogRing(10)
|
|
|
|
withEmail := func(req *http.Request, email string) *http.Request {
|
|
// dispatch reads email from context (ACLMiddleware would normally
|
|
// set it), so set it directly here.
|
|
return req.WithContext(handler.WithEmail(req.Context(), email))
|
|
}
|
|
|
|
// PUT a new file via dispatch. Content is party-scoped under
|
|
// archive/<party>/working/ — the project-level working/ aggregator is
|
|
// virtual (see TestFileAPI_MkdirInAggregatorRejected).
|
|
body := []byte("note body")
|
|
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/working/Acme/note.md", strings.NewReader(string(body))), "alice@example.com")
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT: want 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// GET it back.
|
|
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/working/Acme/note.md", nil), "alice@example.com")
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
|
|
t.Fatalf("GET back: code=%d body=%q", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// MOVE it.
|
|
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/working/Acme/note.md", nil), "alice@example.com")
|
|
req.Header.Set("X-ZDDC-Op", "move")
|
|
req.Header.Set("X-ZDDC-Destination", "/Project-A/working/Acme/renamed.md")
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("MOVE: want 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// DELETE it.
|
|
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/working/Acme/renamed.md", nil), "alice@example.com")
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("DELETE: want 204, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Reserved segment guard still applies to writes.
|
|
req = withEmail(httptest.NewRequest(http.MethodPut, "/.zddc.d/foo.txt", strings.NewReader("x")), "alice@example.com")
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("PUT /.zddc.d/...: want 404, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestDispatchZddcWriteRouting pins the dispatcher's .zddc routing:
|
|
// GET/HEAD lands on ServeZddcFile (which serves the YAML view or the
|
|
// virtual placeholder), and PUT/DELETE/POST falls through past the
|
|
// dot-prefix guard into ServeFileAPI. Before the .zddc-leaf carve-out,
|
|
// PUT/DELETE 405'd at ServeZddcFile (or 404'd at the dot-prefix guard)
|
|
// and the YAML editor's save flow had no live path.
|
|
func TestDispatchZddcWriteRouting(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"admins:\n - admin@example.com\nacl:\n permissions:\n \"*@example.com\": r\n")
|
|
mustMkdir(t, filepath.Join(root, "Project-A"))
|
|
|
|
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",
|
|
MaxWriteBytes: 1 << 20,
|
|
}
|
|
ring := handler.NewLogRing(10)
|
|
|
|
withAuth := func(req *http.Request, email string, elevated bool) *http.Request {
|
|
ctx := handler.WithEmail(req.Context(), email)
|
|
ctx = handler.WithElevation(ctx, elevated)
|
|
return req.WithContext(ctx)
|
|
}
|
|
|
|
// GET routes to ServeZddcFile — serves YAML bytes for an authorised reader.
|
|
req := withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true)
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /.zddc: want 200, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") {
|
|
t.Errorf("GET /.zddc Content-Type = %q, want application/yaml*", ct)
|
|
}
|
|
|
|
// PUT must route to ServeFileAPI (not 405 from ServeZddcFile).
|
|
body := []byte("admins:\n - admin@example.com\n - extra@example.com\n")
|
|
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader(body)), "admin@example.com", true)
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT /.zddc: want 200/201, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Read back via GET to confirm the write landed.
|
|
req = withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true)
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if !strings.Contains(rec.Body.String(), "extra@example.com") {
|
|
t.Errorf("GET after PUT: body missing PUT bytes; got %q", rec.Body.String())
|
|
}
|
|
|
|
// Project-level .zddc that doesn't exist yet — PUT creates it.
|
|
req = withAuth(httptest.NewRequest(http.MethodPut, "/Project-A/.zddc", bytes.NewReader([]byte("title: A\n"))), "admin@example.com", true)
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT /Project-A/.zddc: want 201, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// DELETE removes a .zddc.
|
|
req = withAuth(httptest.NewRequest(http.MethodDelete, "/Project-A/.zddc", nil), "admin@example.com", true)
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("DELETE /Project-A/.zddc: want 204, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Non-admin elevated still 403 on PUT — the carve-out only opens
|
|
// the path past the segment guard; the decider gates ActionAdmin.
|
|
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader([]byte("title: probe\n"))), "stranger@example.com", true)
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("PUT /.zddc by stranger: want 403, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// The reserved .zddc.d/ sidecar is admin-only, but to an admin it's normal
|
|
// files — an elevated root admin can PUT into it.
|
|
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/something", bytes.NewReader([]byte("x"))), "admin@example.com", true)
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT /.zddc.d/something by admin: want 201, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// A non-admin (even elevated) is hard-denied on .zddc.d/ — the dispatch
|
|
// gate 404s it (existence-hidden) before the file API is reached, keeping
|
|
// the token store closed regardless of any operator ACL. (The file API's
|
|
// own authorizeAction 403 is the defense-in-depth layer for direct
|
|
// callers; see fileapi_test.go TestFileAPI_DotContentAllowedButZddcDReserved.)
|
|
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/evil", bytes.NewReader([]byte("x"))), "stranger@example.com", true)
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("PUT /.zddc.d/evil by stranger: want 404, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestDispatchArchiveRedirect: any /<project>/<sub>/.../.archive/... is 302'd
|
|
// to the canonical /<project>/.archive/... so all tracking-number references
|
|
// converge on a single stable URL per (project, tracking) regardless of the
|
|
// folder a relative "../.archive/..." link was resolved from.
|
|
func TestDispatchArchiveRedirect(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"*\": rwcd\n")
|
|
mustMkdir(t, filepath.Join(root, "ProjectA", "Working"))
|
|
|
|
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
|
|
query string
|
|
wantStatus int
|
|
wantLoc string
|
|
}{
|
|
{
|
|
"deep two segments",
|
|
"/ProjectA/Working/.archive/100.html",
|
|
"",
|
|
http.StatusFound,
|
|
"/ProjectA/.archive/100.html",
|
|
},
|
|
{
|
|
"deep three segments",
|
|
"/ProjectA/sub/sub2/.archive/100.html",
|
|
"",
|
|
http.StatusFound,
|
|
"/ProjectA/.archive/100.html",
|
|
},
|
|
{
|
|
"deep with trailing slash (listing)",
|
|
"/ProjectA/Working/.archive/",
|
|
"",
|
|
http.StatusFound,
|
|
"/ProjectA/.archive/",
|
|
},
|
|
{
|
|
"deep with query string preserved",
|
|
"/ProjectA/Working/.archive/100.html",
|
|
"v=42",
|
|
http.StatusFound,
|
|
"/ProjectA/.archive/100.html?v=42",
|
|
},
|
|
{
|
|
"already canonical (no redirect)",
|
|
"/ProjectA/.archive/100.html",
|
|
"",
|
|
// 100.html doesn't resolve in this index (no transmittal
|
|
// folders), so the handler 404s rather than redirecting.
|
|
http.StatusNotFound,
|
|
"",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rawURL := tc.path
|
|
if tc.query != "" {
|
|
rawURL += "?" + tc.query
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != tc.wantStatus {
|
|
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
|
}
|
|
if tc.wantLoc != "" {
|
|
if got := rec.Header().Get("Location"); got != tc.wantLoc {
|
|
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDispatchSlashRouting(t *testing.T) {
|
|
// Convention: <dir>/ → browse (directory view, via DirTool which
|
|
// defaults to browse); <dir> → the directory's default_tool ("the
|
|
// specialized app": browse under working/+reviewing/, transmittal
|
|
// under staging/, archive under archive/, tables under
|
|
// archive/<party>/mdl). Without a default_tool, no-slash falls
|
|
// through to the trailing-slash redirect (302).
|
|
//
|
|
// The only trailing-slash redirect is for a directory that is the
|
|
// rows-dir of a table declared via a REAL on-disk parent .zddc
|
|
// `tables:` map with an existing *.table.yaml spec — it bounces to
|
|
// <parent>/<name>.table.html. The default-MDL virtual fallback at
|
|
// archive/<party>/mdl/ does NOT redirect: the slash form there shows
|
|
// the browse listing of the row YAMLs (the no-slash mdl form serves
|
|
// the table view).
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"*\": rwcda\n")
|
|
for _, sub := range []string{
|
|
"Project/working",
|
|
"Project/staging",
|
|
"Project/archive",
|
|
"Project/archive/Acme",
|
|
"Project/archive/Acme/incoming",
|
|
"Project/archive/Acme/mdl",
|
|
"Project/scratch",
|
|
} {
|
|
mustMkdir(t, filepath.Join(root, sub))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
wantStatus int
|
|
wantNoRedirect bool
|
|
wantLoc string // checked when wantStatus is a redirect
|
|
}{
|
|
{"working no-slash → browse", "/Project/working", http.StatusOK, true, ""},
|
|
{"working slash → browse", "/Project/working/", http.StatusOK, true, ""},
|
|
{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true, ""},
|
|
{"staging slash → browse", "/Project/staging/", http.StatusOK, true, ""},
|
|
{"archive no-slash → archive", "/Project/archive", http.StatusOK, true, ""},
|
|
{"archive slash → browse", "/Project/archive/", http.StatusOK, true, ""},
|
|
{"archive/<party> no-slash → archive", "/Project/archive/Acme", http.StatusOK, true, ""},
|
|
{"archive/<party> slash → browse", "/Project/archive/Acme/", http.StatusOK, true, ""},
|
|
{"archive/<party>/mdl no-slash → tables", "/Project/archive/Acme/mdl", http.StatusOK, true, ""},
|
|
// The default-MDL virtual fallback does NOT redirect the slash
|
|
// form — it shows the browse listing of the row YAMLs. (Only a
|
|
// real on-disk parent .zddc tables: + *.table.yaml triggers the
|
|
// bounce to <parent>/<name>.table.html.)
|
|
{"archive/<party>/mdl slash → browse", "/Project/archive/Acme/mdl/", http.StatusOK, true, ""},
|
|
{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true, ""},
|
|
{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""},
|
|
{"non-canonical no-slash → 302 to slash", "/Project/scratch", http.StatusFound, false, ""},
|
|
{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true, ""},
|
|
// Project root no-slash → synthetic landing page (handler.ServeProjectLanding).
|
|
{"project root no-slash → landing", "/Project", http.StatusOK, true, ""},
|
|
}
|
|
|
|
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, appsSrv, nil, rec, req)
|
|
if rec.Code != tc.wantStatus {
|
|
t.Fatalf("path=%q status=%d, want %d; body=%s",
|
|
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
|
}
|
|
if tc.wantNoRedirect && rec.Code >= 300 && rec.Code < 400 {
|
|
t.Errorf("path=%q unexpected redirect to %q",
|
|
tc.path, rec.Header().Get("Location"))
|
|
}
|
|
if tc.wantLoc != "" {
|
|
if got := rec.Header().Get("Location"); got != tc.wantLoc {
|
|
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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, "[]")
|
|
}
|
|
})
|
|
}
|
|
|
|
// No-trailing-slash form on a canonical peer → its default app.
|
|
// In the flat-peer layout these are physical peers: working/reviewing
|
|
// default to browse, staging to transmittal, archive to the archive
|
|
// tool.
|
|
noSlashDefaultApp := []struct {
|
|
stage string
|
|
expect string // substring that should appear in the response body
|
|
}{
|
|
{"working", "ZDDC Browse"},
|
|
{"staging", "ZDDC Transmittal"},
|
|
{"archive", "ZDDC Archive"},
|
|
{"reviewing", "ZDDC Browse"},
|
|
}
|
|
for _, tc := range noSlashDefaultApp {
|
|
t.Run("no-slash/"+tc.stage+" → default app", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/Project/"+tc.stage, nil)
|
|
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())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), tc.expect) {
|
|
t.Errorf("%s/ body missing %q", tc.stage, tc.expect)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Trailing-slash form on a canonical folder serves the browse
|
|
// app for HTML requests — same convention as the existing IsDir
|
|
// branch. The slash-vs-no-slash distinction is the user's signal:
|
|
// "show me the directory contents" vs "open the default tool".
|
|
for _, stage := range []string{"working", "staging", "archive", "reviewing"} {
|
|
t.Run("slash/"+stage+" → browse", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/Project/"+stage+"/", nil)
|
|
req.Header.Set("Accept", "text/html")
|
|
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())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "ZDDC Browse") {
|
|
t.Errorf("%s/ HTML response missing 'ZDDC Browse'", stage)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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
|
|
// anything (and so a 302 redirect can never silently downgrade a write
|
|
// to a GET on the canonical URL).
|
|
func TestDispatchArchiveMethodGate(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"*\": rwcd\n")
|
|
mustMkdir(t, filepath.Join(root, "ProjectA"))
|
|
|
|
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",
|
|
MaxWriteBytes: 1 << 20,
|
|
}
|
|
ring := handler.NewLogRing(10)
|
|
|
|
for _, method := range []string{http.MethodPut, http.MethodPost, http.MethodDelete} {
|
|
for _, path := range []string{
|
|
"/ProjectA/.archive/100.html",
|
|
"/ProjectA/Working/.archive/100.html",
|
|
} {
|
|
t.Run(method+" "+path, func(t *testing.T) {
|
|
req := httptest.NewRequest(method, path, strings.NewReader("body"))
|
|
req = req.WithContext(handler.WithEmail(req.Context(), "alice@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("%s %s: status %d, want 405; body=%s", method, path, rec.Code, rec.Body.String())
|
|
}
|
|
if allow := rec.Header().Get("Allow"); !strings.Contains(allow, "GET") {
|
|
t.Errorf("%s %s: Allow=%q, want to contain GET", method, path, allow)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDispatchCaseInsensitiveURL: mixed-case URLs resolve to the on-disk
|
|
// canonical case, with the lowercase variant winning when both case
|
|
// variants exist as siblings on disk.
|
|
func TestDispatchCaseInsensitiveURL(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"*\": rwcd\n")
|
|
mustMkdir(t, filepath.Join(root, "project-a", "working"))
|
|
mustWrite(t, filepath.Join(root, "project-a", "working", "note.md"), "lowercase note")
|
|
|
|
// Sibling Mixed-Case dir present too. Lowercase must win on the
|
|
// case-insensitive resolution; the Mixed-Case dir's contents must
|
|
// not bleed through under any URL casing.
|
|
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
|
|
mustWrite(t, filepath.Join(root, "Project-A", "Working", "note.md"), "MIXEDCASE note")
|
|
|
|
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
|
|
url string
|
|
}{
|
|
{"all lowercase", "/project-a/working/note.md"},
|
|
{"mixed case top", "/Project-A/working/note.md"},
|
|
{"mixed case nested", "/PROJECT-A/Working/Note.md"},
|
|
{"all uppercase", "/PROJECT-A/WORKING/NOTE.MD"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
|
|
}
|
|
if got := rec.Body.String(); got != "lowercase note" {
|
|
t.Errorf("body=%q want %q (lowercase variant must win)",
|
|
got, "lowercase note")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 mustWriteZip(t *testing.T, path string, entries map[string]string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("mkdir for zip %s: %v", path, err)
|
|
}
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatalf("create zip %s: %v", path, err)
|
|
}
|
|
defer f.Close()
|
|
zw := zip.NewWriter(f)
|
|
for name, body := range entries {
|
|
w, err := zw.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("zip.Create(%q): %v", name, err)
|
|
}
|
|
if _, err := w.Write([]byte(body)); err != nil {
|
|
t.Fatalf("zip write %q: %v", name, err)
|
|
}
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("zip close %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
// TestDispatchZipRouting exercises the .zip-as-virtual-directory
|
|
// intercept: <…>.zip/ lists members, <…>.zip/member streams one
|
|
// member, bare <…>.zip is still a plain file download, writes into a
|
|
// zip are refused, and ACL is inherited from the directory containing
|
|
// the zip (a zip has no .zddc of its own — same as .archive).
|
|
func TestDispatchZipRouting(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Only alice@x may read under staging/; bob@x is denied there.
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"*\": r\n")
|
|
mustMkdir(t, filepath.Join(root, "Proj", "staging"))
|
|
mustWrite(t, filepath.Join(root, "Proj", "staging", ".zddc"),
|
|
"acl:\n inherit: false\n permissions:\n \"alice@x\": rwcda\n")
|
|
zipPath := filepath.Join(root, "Proj", "staging", "T.zip")
|
|
mustWriteZip(t, zipPath, map[string]string{
|
|
"DOC-001.pdf": "PDFDATA",
|
|
"sub/note.txt": "a note",
|
|
})
|
|
zipBytes, _ := os.ReadFile(zipPath)
|
|
|
|
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)
|
|
}
|
|
|
|
do := func(method, path, email string, hdr map[string]string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
for k, v := range hdr {
|
|
req.Header.Set(k, v)
|
|
}
|
|
req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, email))
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
|
return rec
|
|
}
|
|
|
|
t.Run("listing JSON", func(t *testing.T) {
|
|
rec := do(http.MethodGet, "/Proj/staging/T.zip/", "alice@x", map[string]string{"Accept": "application/json"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var fis []map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &fis); err != nil {
|
|
t.Fatalf("decode listing: %v; body=%s", err, rec.Body.String())
|
|
}
|
|
names := map[string]bool{}
|
|
for _, fi := range fis {
|
|
names[fi["name"].(string)] = fi["is_dir"] == true
|
|
}
|
|
if d, ok := names["DOC-001.pdf"]; !ok || d {
|
|
t.Errorf("expected file DOC-001.pdf; got %v", names)
|
|
}
|
|
if d, ok := names["sub/"]; !ok || !d {
|
|
t.Errorf("expected dir sub/; got %v", names)
|
|
}
|
|
})
|
|
|
|
t.Run("member extracted", func(t *testing.T) {
|
|
rec := do(http.MethodGet, "/Proj/staging/T.zip/sub/note.txt", "alice@x", nil)
|
|
if rec.Code != http.StatusOK || rec.Body.String() != "a note" {
|
|
t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Header().Get("X-ZDDC-Source") != "zip:T.zip" {
|
|
t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
|
|
}
|
|
})
|
|
|
|
t.Run("bare .zip is a plain download", func(t *testing.T) {
|
|
rec := do(http.MethodGet, "/Proj/staging/T.zip", "alice@x", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
if rec.Body.Len() != len(zipBytes) {
|
|
t.Errorf("bare .zip body len=%d, want %d (raw zip bytes)", rec.Body.Len(), len(zipBytes))
|
|
}
|
|
// It must NOT have the zip-virtual-dir source header.
|
|
if rec.Header().Get("X-ZDDC-Source") == "zip:T.zip" {
|
|
t.Errorf("bare .zip should be served as a file, not the virtual-dir handler")
|
|
}
|
|
})
|
|
|
|
t.Run("write into zip refused", func(t *testing.T) {
|
|
rec := do(http.MethodPut, "/Proj/staging/T.zip/new.txt", "alice@x", nil)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("PUT into zip status=%d, want 405", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("ACL inherited from containing dir — denied", func(t *testing.T) {
|
|
rec := do(http.MethodGet, "/Proj/staging/T.zip/sub/note.txt", "bob@x", nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("bob denied under staging/ → zip member status=%d, want 403", rec.Code)
|
|
}
|
|
rec2 := do(http.MethodGet, "/Proj/staging/T.zip/", "bob@x", map[string]string{"Accept": "application/json"})
|
|
if rec2.Code != http.StatusForbidden {
|
|
t.Errorf("bob denied → zip listing status=%d, want 403", rec2.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("missing member 404", func(t *testing.T) {
|
|
rec := do(http.MethodGet, "/Proj/staging/T.zip/no/such.txt", "alice@x", nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status=%d, want 404", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("directory member 302 to slash", func(t *testing.T) {
|
|
rec := do(http.MethodGet, "/Proj/staging/T.zip/sub", "alice@x", nil)
|
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/Proj/staging/T.zip/sub/" {
|
|
t.Errorf("status=%d loc=%q", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestGzhttpWrapper_CompressesLargeResponses asserts the gzhttp wrapper
|
|
// behavior we wire in main(): responses above MinSize get gzip-encoded
|
|
// when the client advertises Accept-Encoding: gzip; small responses
|
|
// pass through uncompressed; HEAD requests still set Vary correctly.
|
|
//
|
|
// We construct the wrapper the same way main() does (1024 byte minsize)
|
|
// and exercise it against a tiny test handler — full end-to-end is
|
|
// covered by the live curl smoke test in CI / dev verification.
|
|
func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
|
|
// Re-create the wrapper config from main.go so this test stays in
|
|
// sync with the real wiring.
|
|
wrapper, err := newGzipWrapper()
|
|
if err != nil {
|
|
t.Fatalf("newGzipWrapper: %v", err)
|
|
}
|
|
|
|
largeBody := strings.Repeat("ZDDC ", 4000) // ~20 KB, well over MinSize
|
|
smallBody := "ok"
|
|
|
|
handler := wrapper(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if r.URL.Path == "/large" {
|
|
_, _ = w.Write([]byte(largeBody))
|
|
} else {
|
|
_, _ = w.Write([]byte(smallBody))
|
|
}
|
|
}))
|
|
|
|
srv := httptest.NewServer(handler)
|
|
defer srv.Close()
|
|
|
|
t.Run("large body with Accept-Encoding gzip → compressed", func(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/large", nil)
|
|
req.Header.Set("Accept-Encoding", "gzip")
|
|
// Disable transparent decompression so we can read the raw bytes
|
|
// and confirm the wire format.
|
|
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
|
|
t.Errorf("Content-Encoding = %q, want gzip", got)
|
|
}
|
|
if got := resp.Header.Get("Vary"); !strings.Contains(strings.ToLower(got), "accept-encoding") {
|
|
t.Errorf("Vary = %q, want to contain Accept-Encoding", got)
|
|
}
|
|
})
|
|
|
|
t.Run("small body → not compressed", func(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/small", nil)
|
|
req.Header.Set("Accept-Encoding", "gzip")
|
|
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if got := resp.Header.Get("Content-Encoding"); got == "gzip" {
|
|
t.Errorf("Content-Encoding = gzip; small response should not be compressed")
|
|
}
|
|
})
|
|
|
|
t.Run("no Accept-Encoding → not compressed", func(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/large", nil)
|
|
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if got := resp.Header.Get("Content-Encoding"); got != "" {
|
|
t.Errorf("Content-Encoding = %q; client without Accept-Encoding should get plain", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// (TestDispatchHistoryReadCarveOut was removed: history snapshots now live
|
|
// under the reserved .zddc.d/history/ namespace — blocked raw by the
|
|
// dot-prefix guard, like any bookkeeping, and surfaced only through the
|
|
// history endpoints. Raw-block coverage is in TestDispatchHidesDotPrefixedSegments;
|
|
// the viewer is covered in mdhistory_test.go.)
|
|
|
|
// writeRootBundle writes <root>/.zddc.zip containing the given members.
|
|
// Used by dispatch tests exercising the local tool-HTML bundle override.
|
|
func writeRootBundle(t *testing.T, root string, members map[string]string) {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
for name, body := range members {
|
|
w, err := zw.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("zip create %s: %v", name, err)
|
|
}
|
|
if _, err := w.Write([]byte(body)); err != nil {
|
|
t.Fatalf("zip write %s: %v", name, err)
|
|
}
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("zip close: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc.zip"), buf.Bytes(), 0o644); err != nil {
|
|
t.Fatalf("write bundle: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDispatchFileToFormView locks in the views.file → form shape: a browser
|
|
// NAVIGATION (Accept: text/html) to a no-slash data file, in a dir whose
|
|
// cascade declares views.file = {tool: form}, serves the form editor bound to
|
|
// that file — while a programmatic fetch (Accept: */*, the tables client) and
|
|
// an explicit ?raw still get raw bytes, so row fetching never breaks. A dir
|
|
// without views.file keeps serving raw bytes on navigation too.
|
|
func TestDispatchFileToFormView(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"*\": rwcd\n")
|
|
dir := filepath.Join(root, "Proj", "records")
|
|
mustMkdir(t, dir)
|
|
// views.file declared on the records dir → form editor for its files.
|
|
mustWrite(t, filepath.Join(dir, ".zddc"),
|
|
"views:\n file:\n tool: form\n")
|
|
// Form schema lives in the supporting-files reserve.
|
|
mustMkdir(t, filepath.Join(dir, ".zddc.d"))
|
|
mustWrite(t, filepath.Join(dir, ".zddc.d", "form.yaml"),
|
|
"schema:\n type: object\n properties:\n title:\n type: string\n")
|
|
mustWrite(t, filepath.Join(dir, "rec1.yaml"), "title: Hello\n")
|
|
// A sibling file in a dir WITHOUT views.file stays raw.
|
|
mustWrite(t, filepath.Join(root, "Proj", "plain.yaml"), "x: 1\n")
|
|
|
|
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)
|
|
}
|
|
|
|
do := func(path string, hdr map[string]string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
for k, v := range hdr {
|
|
req.Header.Set(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
|
return rec
|
|
}
|
|
|
|
// Navigation → form editor HTML.
|
|
rec := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "text/html"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("navigation: status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
|
t.Errorf("navigation Content-Type=%q, want text/html (form)", ct)
|
|
}
|
|
if strings.Contains(rec.Body.String(), "title: Hello") {
|
|
t.Errorf("navigation served raw YAML, want the form editor")
|
|
}
|
|
|
|
// Programmatic fetch (Accept: */*) → raw YAML bytes.
|
|
rec2 := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "*/*"})
|
|
if rec2.Code != http.StatusOK || !strings.Contains(rec2.Body.String(), "title: Hello") {
|
|
t.Errorf("fetch: status=%d body=%q, want raw YAML", rec2.Code, rec2.Body.String())
|
|
}
|
|
|
|
// ?raw escape hatch → raw bytes even for a browser.
|
|
rec3 := do("/Proj/records/rec1.yaml?raw=1", map[string]string{"Accept": "text/html"})
|
|
if !strings.Contains(rec3.Body.String(), "title: Hello") {
|
|
t.Errorf("?raw body=%q, want raw YAML", rec3.Body.String())
|
|
}
|
|
|
|
// No views.file declared → navigation still serves raw bytes.
|
|
rec4 := do("/Proj/plain.yaml", map[string]string{"Accept": "text/html"})
|
|
if !strings.Contains(rec4.Body.String(), "x: 1") {
|
|
t.Errorf("no-views file body=%q, want raw YAML", rec4.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestDispatchBundleAdminView locks in admin-mode visibility of the site-root
|
|
// .zddc.zip config bundle: an active (elevated) admin may browse it as a zip
|
|
// directory (list members, extract a member) and download it, while everyone
|
|
// else — including the same admin un-elevated, and non-admins — gets 404 for
|
|
// every bundle URL shape (closing the previous by-name member read).
|
|
func TestDispatchBundleAdminView(t *testing.T) {
|
|
root := t.TempDir()
|
|
// alice is a root admin; bob is a plain reader.
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"alice@x\": rwcda\n \"bob@x\": r\nadmins:\n - alice@x\n")
|
|
writeRootBundle(t, root, map[string]string{
|
|
"archive.html": "<!doctype html>BUNDLE archive",
|
|
"sub/note.txt": "a member note",
|
|
})
|
|
|
|
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)
|
|
}
|
|
|
|
do := func(path, email string, elevated bool) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
ctx := context.WithValue(req.Context(), handler.EmailKey, email)
|
|
ctx = context.WithValue(ctx, handler.ElevatedKey, elevated)
|
|
req = req.WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
|
return rec
|
|
}
|
|
|
|
// Elevated admin: member listing, member extract, and bare download all work.
|
|
if rec := do("/.zddc.zip/", "alice@x", true); rec.Code != http.StatusOK {
|
|
t.Errorf("admin GET /.zddc.zip/ : status=%d body=%s, want 200 listing", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := do("/.zddc.zip/archive.html", "alice@x", true); rec.Code != http.StatusOK ||
|
|
!strings.Contains(rec.Body.String(), "BUNDLE archive") {
|
|
t.Errorf("admin GET member: status=%d body=%s, want 200 member bytes", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := do("/.zddc.zip", "alice@x", true); rec.Code != http.StatusOK {
|
|
t.Errorf("admin GET bare /.zddc.zip : status=%d, want 200 download", rec.Code)
|
|
}
|
|
|
|
// Same admin un-elevated → 404 (sudo model: powers are per-request).
|
|
if rec := do("/.zddc.zip/", "alice@x", false); rec.Code != http.StatusNotFound {
|
|
t.Errorf("un-elevated admin GET /.zddc.zip/ : status=%d, want 404", rec.Code)
|
|
}
|
|
// Non-admin reader → 404 for listing AND by-name member (no leak).
|
|
if rec := do("/.zddc.zip/", "bob@x", true); rec.Code != http.StatusNotFound {
|
|
t.Errorf("non-admin GET /.zddc.zip/ : status=%d, want 404", rec.Code)
|
|
}
|
|
if rec := do("/.zddc.zip/archive.html", "bob@x", true); rec.Code != http.StatusNotFound {
|
|
t.Errorf("non-admin GET member: status=%d, want 404 (no by-name leak)", rec.Code)
|
|
}
|
|
}
|