package main import ( "archive/zip" "bytes" "context" "crypto/ed25519" "crypto/rand" "encoding/json" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "codeberg.org/VARASYS/ZDDC/zddc/internal/apps" "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" ) // TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that // rejects requests whose URL contains a dot-prefixed segment (other than // the recognized virtual prefixes .archive and /.profile handled separately). // // The guard exists so the in-image dev-shell can keep persistent state // (settings, source clones, Go module cache) under /srv/.devshell on the // same Azure Files PVC as served data without ever exposing those files // via direct HTTP fetch. func TestDispatchHidesDotPrefixedSegments(t *testing.T) { root := t.TempDir() // Realistic shape: a project dir, a hidden top-level dir, 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, ".devshell")) mustMkdir(t, filepath.Join(root, ".devshell", "coder")) mustWrite(t, filepath.Join(root, ".devshell", "coder", "settings.json"), "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 }{ // Hidden top-level dir — every shape blocked. {"hidden top dir", "/.devshell/", http.StatusNotFound}, {"hidden top dir nested", "/.devshell/coder/settings.json", http.StatusNotFound}, // Hidden segment under a real project dir — also blocked. {"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound}, // Sanity: recognized virtual prefixes are NOT blocked. .archive falls // through to its own handler (which 404s on missing tracking number); // .profile is handled by ServeProfile and the page itself is public. // /.admin no longer exists — it is hard-cut and falls through to the // dot-prefix guard, which 404s. {".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 hard-cut → dot-prefix guard", "/.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 the full apps fetch+cache flow through // dispatch() with a fake upstream. Confirms that: // - GET / serves the landing app from the apps subsystem // - GET /archive.html serves the archive app via fetch+cache // - second GET /archive.html serves from cache (X-ZDDC-Source: cache:) // - direct URL access to /_zddc/... is rejected func TestDispatchAppsResolution(t *testing.T) { root := t.TempDir() body := []byte("archive content") pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("GenerateKey: %v", err) } sig := ed25519.Sign(priv, body) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Same body for every artifact; same signature for every .sig // (since the body is identical across the five tools in this // fixture). Real deployments publish a distinct .sig per // artifact; the test only cares that the verify gate passes. if strings.HasSuffix(r.URL.Path, ".sig") { _, _ = w.Write(sig) return } w.Header().Set("ETag", `"v1"`) _, _ = w.Write(body) })) defer upstream.Close() upstreamURL, _ := url.Parse(upstream.URL) upstreamHost := upstreamURL.Host if i := strings.Index(upstreamHost, ":"); i >= 0 { upstreamHost = upstreamHost[:i] } _ = upstreamHost // referenced below // Seed root .zddc with subdir-cascade Apps entries pointing at the // fake upstream. Allow all email patterns (anonymous) so the test // doesn't have to set up email headers. zf := zddc.ZddcFile{ ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}}, Apps: map[string]string{ "archive": upstream.URL + "/archive_stable.html", "transmittal": upstream.URL + "/transmittal_stable.html", "classifier": upstream.URL + "/classifier_stable.html", "landing": upstream.URL + "/landing_stable.html", "browse": upstream.URL + "/browse_stable.html", }, } if err := zddc.WriteFile(root, zf); err != nil { t.Fatalf("WriteFile: %v", err) } // Create folder convention dirs so classifier/browse/transmittal // availability rules pass for the test paths used below. mustMkdir(t, filepath.Join(root, "Project-A", "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) appsSrv, err := setupApps(cfg) if err != nil { t.Fatalf("setupApps: %v", err) } // Override the production embedded public key with the test fixture's // pubkey so signature verification of upstream.Sign'd bodies succeeds. appsSrv.Fetcher.VerifyKey = pub // GET /archive.html → fetched from upstream (archive is available everywhere) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/archive.html", nil) dispatch(cfg, idx, ring, appsSrv, nil, rec, req) if rec.Code != http.StatusOK { t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String()) } if rec.Body.String() != string(body) { t.Errorf("first /archive.html: body mismatch") } // GET /archive.html again → cache hit (no new upstream fetch) rec2 := httptest.NewRecorder() dispatch(cfg, idx, ring, appsSrv, nil, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil)) if rec2.Code != http.StatusOK { t.Errorf("second /archive.html: status=%d", rec2.Code) } // GET / → landing 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) } // Direct URL access to /_app/ → 404 rec4 := httptest.NewRecorder() dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/_app/foo.html", nil)) if rec4.Code != http.StatusNotFound { t.Errorf("/_app/ direct: 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//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/archive/Acme/Working/classifier.html", nil)) if rec6.Code != http.StatusOK { t.Errorf("/Project-A/archive/Acme/Working/classifier.html: status=%d, want 200", rec6.Code) } } // silence "imported and not used" if apps not referenced elsewhere — keep // import even when we trim test cases later. var _ = apps.DefaultUpstream // 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")) 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. body := []byte("note body") req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/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/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/note.md", nil), "alice@example.com") req.Header.Set("X-ZDDC-Op", "move") req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/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/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, "/.devshell/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 /.devshell/...: 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()) } // Intermediate .zddc.d segments stay reserved — only the LEAF .zddc // is carved through. A PUT to /.zddc.d/foo must 404 at the guard. 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.StatusNotFound { t.Fatalf("PUT /.zddc.d/something: want 404 (reserved segment), got %d", rec.Code) } } // TestDispatchArchiveRedirect: any ///.../.archive/... is 302'd // to the canonical //.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: / → browse (directory view, via DirTool which // defaults to browse); → the directory's default_tool ("the // specialized app": browse under working/+reviewing/, transmittal // under staging/, archive under archive/, tables under // archive//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 // /.table.html. The default-MDL virtual fallback at // archive//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/ no-slash → archive", "/Project/archive/Acme", http.StatusOK, true, ""}, {"archive/ slash → browse", "/Project/archive/Acme/", http.StatusOK, true, ""}, {"archive//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 /.table.html.) {"archive//mdl slash → browse", "/Project/archive/Acme/mdl/", http.StatusOK, true, ""}, {"archive//incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true, ""}, {"archive//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 /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 folder → default app. // Under the reshape, the project-root staging/reviewing/working // URLs are folder-nav virtuals served by browse (the per-party // transmittal default lives at archive//staging/). archive/ // is still the archive tool. noSlashDefaultApp := []struct { stage string expect string // substring that should appear in the response body }{ {"working", "ZDDC Browse"}, {"staging", "ZDDC Browse"}, {"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) } }) }