Add github.com/klauspost/compress/gzhttp wrapper around the request
handler. With MinSize(1024), responses ≥ 1 KB get gzip-encoded when
the client advertises Accept-Encoding: gzip; smaller bodies + 304
Not Modified pass through unchanged.
The wrapper auto-appends Vary: Accept-Encoding (compatible with the
existing Vary: Accept on directory.go's content-negotiated path).
Live-tested against zddc-server -root /tmp/empty:
GET / w/ Accept-Encoding: gzip → 20.9 KB compressed (was 80.9 KB
uncompressed). 74% reduction.
Decompresses cleanly back to the original bytes.
Helps every code path that bypasses Caddy: devshell pods, local dev
binaries, tests, anywhere zddc-server is hit directly. Production
behind Caddy already had compression at the proxy layer; this just
makes the Go server self-sufficient.
Tests in cmd/zddc-server/main_test.go cover:
- large body + Accept-Encoding → compressed + Vary header
- small body → not compressed (under MinSize)
- no Accept-Encoding header → plain bytes
290 lines
10 KiB
Go
290 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"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("<!doctype html>archive content")
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|