package main import ( "crypto/ed25519" "crypto/rand" "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, 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{Allow: []string{"*"}}, Apps: map[string]string{ "archive": upstream.URL + "/archive_stable.html", "transmittal": upstream.URL + "/transmittal_stable.html", "classifier": upstream.URL + "/classifier_stable.html", "mdedit": upstream.URL + "/mdedit_stable.html", "landing": upstream.URL + "/landing_stable.html", }, } if err := zddc.WriteFile(root, zf); err != nil { t.Fatalf("WriteFile: %v", err) } // Create folder convention dirs so classifier/mdedit/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, 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, 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, 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, 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 Incoming/Working/Staging ancestor), but SHOULD work in // /Project-A/Working/. rec5 := httptest.NewRecorder() dispatch(cfg, idx, ring, appsSrv, 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 Incoming/Working/Staging)", rec5.Code) } rec6 := httptest.NewRecorder() dispatch(cfg, idx, ring, appsSrv, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil)) if rec6.Code != http.StatusOK { t.Errorf("/Project-A/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 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 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) } }) }