1277 lines
50 KiB
Go
1277 lines
50 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 → STILL visible: config-edit is standing, so a
|
|
// subtree admin browses the bundle without elevating (elevation only adds
|
|
// the WORM/destructive overrides, not config visibility/edit).
|
|
if rec := do("/.zddc.zip/", "alice@x", false); rec.Code != http.StatusOK {
|
|
t.Errorf("un-elevated admin GET /.zddc.zip/ : status=%d, want 200 (standing config-edit)", rec.Code)
|
|
}
|
|
// Non-admin reader (bob has `r` but no admin/`a`) → 404 for listing AND
|
|
// by-name member: the bundle is scoped to config-EDITORS, not all readers
|
|
// (one file packs many subtrees' policy — per-level transparency is
|
|
// ServeZddcFile's job, not the bundle's).
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestDispatchBundleAdminWrite locks in edit-in-place for the .zddc.zip config
|
|
// bundle: an active admin can PUT/DELETE members (changing live policy), each
|
|
// edit snapshots the prior version into an in-zip .history/, non-admins get 404
|
|
// (the bundle gate), and content zips stay read-only.
|
|
func TestDispatchBundleAdminWrite(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"alice@x\": rwcda\nadmins:\n - alice@x\n")
|
|
mustMkdir(t, filepath.Join(root, "Proj"))
|
|
writeRootBundle(t, root, map[string]string{"browse.html": "<!doctype html>BUNDLE"})
|
|
mustWriteZip(t, filepath.Join(root, "Proj", "Foo.zip"), map[string]string{"m.txt": "x"})
|
|
|
|
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: 64 * 1024}
|
|
ring := handler.NewLogRing(10)
|
|
appsSrv, err := setupApps(cfg)
|
|
if err != nil {
|
|
t.Fatalf("setupApps: %v", err)
|
|
}
|
|
do := func(method, path, email string, elevated bool, body []byte) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if body != nil {
|
|
req = httptest.NewRequest(method, path, bytes.NewReader(body))
|
|
} else {
|
|
req = httptest.NewRequest(method, 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
|
|
}
|
|
|
|
// 1. Admin creates a policy member (governs the project level via "*").
|
|
if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "alice@x", true,
|
|
[]byte("acl:\n permissions:\n \"team@x\": rwc\n")); rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT new member: status=%d body=%s, want 201", rec.Code, rec.Body.String())
|
|
}
|
|
// 2. The edit took effect on the live cascade (write invalidated the cache).
|
|
zddc.InvalidateCache(root)
|
|
chain, _ := zddc.EffectivePolicy(root, filepath.Join(root, "Proj"))
|
|
if !zddc.EffectiveVerbs(chain, "team@x").Has(zddc.VerbC) {
|
|
t.Errorf("bundle policy edit didn't reach the cascade: team@x lacks create at /Proj")
|
|
}
|
|
// 3. Edit again (existing member → snapshots to .history/).
|
|
if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "alice@x", true,
|
|
[]byte("acl:\n permissions:\n \"team@x\": r\n")); rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT overwrite: status=%d, want 200", rec.Code)
|
|
}
|
|
// 4. Read back the current member.
|
|
if rec := do(http.MethodGet, "/.zddc.zip/Proj/.zddc", "alice@x", true, nil); !strings.Contains(rec.Body.String(), "\"team@x\": r") {
|
|
t.Errorf("read-back body=%q, want the latest edit", rec.Body.String())
|
|
}
|
|
// 5. The in-zip history log records the edit (audited with the editor email).
|
|
if rec := do(http.MethodGet, "/.zddc.zip/.history/Proj/.zddc/log.jsonl", "alice@x", true, nil); !strings.Contains(rec.Body.String(), "alice@x") {
|
|
t.Errorf("history log=%q, want an alice@x entry", rec.Body.String())
|
|
}
|
|
// 5b. Un-elevated config-editor can ALSO write the bundle — config-edit is
|
|
// standing for the bundle too, no toggle required.
|
|
if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "alice@x", false,
|
|
[]byte("acl:\n permissions:\n \"team@x\": rw\n")); rec.Code != http.StatusOK {
|
|
t.Errorf("un-elevated config-editor PUT bundle: status=%d, want 200 (standing)", rec.Code)
|
|
}
|
|
// 6. Non-admin write → 404 (bundle existence-hidden to non config-editors).
|
|
if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "bob@x", true, []byte("x")); rec.Code != http.StatusNotFound {
|
|
t.Errorf("non-admin PUT: status=%d, want 404", rec.Code)
|
|
}
|
|
// 7. Content zips stay read-only — even for an admin.
|
|
if rec := do(http.MethodPut, "/Proj/Foo.zip/m.txt", "alice@x", true, []byte("y")); rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("content-zip PUT: status=%d, want 405", rec.Code)
|
|
}
|
|
}
|