package apps import ( "crypto/ed25519" "crypto/rand" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "sync/atomic" "testing" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // signedFixture returns a (publicKey, handler) pair where the handler // serves `body` for any URL ending in `.html` and the corresponding // Ed25519 signature for the same URL with `.sig` appended. Tests use // this to stand up upstream stubs that exercise the apps fetcher's // strict signature-verification path. // // All tests share one pattern: the fetcher's VerifyKey gets overridden // to this fixture's publicKey so verification passes against the // fixture's signature instead of the production embedded key. func signedFixture(t *testing.T, body []byte) (ed25519.PublicKey, http.HandlerFunc) { t.Helper() pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("GenerateKey: %v", err) } sig := ed25519.Sign(priv, body) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, ".sig"): _, _ = w.Write(sig) default: _, _ = w.Write(body) } }) return pub, handler } func TestMatchAppHTML(t *testing.T) { cases := []struct { path, wantApp, wantDir string }{ {"/", "landing", ""}, {"/index.html", "landing", ""}, {"/archive.html", "archive", ""}, {"/Project-X/archive.html", "archive", "Project-X"}, {"/Project-X/Working/mdedit.html", "mdedit", "Project-X/Working"}, {"/foo.html", "", ""}, } for _, tc := range cases { t.Run(tc.path, func(t *testing.T) { gotApp, gotDir := MatchAppHTML(tc.path) if gotApp != tc.wantApp || gotDir != tc.wantDir { t.Errorf("got (%q,%q), want (%q,%q)", gotApp, gotDir, tc.wantApp, tc.wantDir) } }) } } // Build a Server with a fake upstream serving body. The upstream // also publishes a valid Ed25519 signature alongside (.sig) and the // fetcher's VerifyKey is overridden to the matching test pubkey so // fetched bytes pass the strict-signature gate. func newTestServer(t *testing.T, body []byte) (*Server, *httptest.Server, string) { t.Helper() pub, handler := signedFixture(t, body) upstream := httptest.NewServer(handler) t.Cleanup(upstream.Close) root := t.TempDir() cache, err := NewCache(filepath.Join(root, CacheDirName)) if err != nil { t.Fatal(err) } f := NewFetcher(cache, nil) f.VerifyKey = pub return NewServer(root, cache, f, "test"), upstream, root } func TestServer_NoOverride_ServesEmbedded(t *testing.T) { srv, _, root := newTestServer(t, []byte("upstream body")) saved := embeddedArchive embeddedArchive = []byte("EMBEDDED archive") defer func() { embeddedArchive = saved }() chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} rec := httptest.NewRecorder() srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } if !strings.Contains(rec.Body.String(), "EMBEDDED") { t.Errorf("expected embedded body, got %q", rec.Body.String()) } if !strings.HasPrefix(rec.Header().Get("X-ZDDC-Source"), "embedded:archive@") { t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source")) } } func TestServer_OverrideURL_FetchesAndCaches(t *testing.T) { body := []byte("from upstream") srv, up, root := newTestServer(t, body) chain := zddc.PolicyChain{ Levels: []zddc.ZddcFile{{ Apps: map[string]string{"archive": up.URL + "/archive_stable.html"}, }}, } rec := httptest.NewRecorder() srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } if rec.Body.String() != string(body) { t.Errorf("body mismatch") } // Cache should be populated. if !srv.Cache.Has(up.URL + "/archive_stable.html") { t.Errorf("cache miss after fetch") } } func TestServer_OverrideURL_CacheHitOnSecondCall(t *testing.T) { var hits atomic.Int64 body := []byte("body") pub, _, sig := func() (ed25519.PublicKey, ed25519.PrivateKey, []byte) { p, k, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("GenerateKey: %v", err) } return p, k, ed25519.Sign(k, body) }() upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Count only artifact fetches (not .sig fetches) so the assertion // "1 hit means cache works" stays meaningful: cache stores the // artifact body, signature verification re-runs each time the // resolver hits the URL but only on the first miss does it fetch // the artifact bytes itself. After that, cache.Read short-circuits. if !strings.HasSuffix(r.URL.Path, ".sig") { hits.Add(1) _, _ = w.Write(body) return } _, _ = w.Write(sig) })) defer upstream.Close() root := t.TempDir() cache, _ := NewCache(filepath.Join(root, CacheDirName)) f := NewFetcher(cache, nil) f.VerifyKey = pub srv := NewServer(root, cache, f, "test") chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ Apps: map[string]string{"archive": upstream.URL + "/archive_stable.html"}, }}} for i := 0; i < 3; i++ { rec := httptest.NewRecorder() srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root) if rec.Code != http.StatusOK { t.Fatalf("call %d status=%d", i, rec.Code) } } if hits.Load() != 1 { t.Errorf("upstream fetched %d times, want exactly 1 (cache forever)", hits.Load()) } } func TestServer_PathOverride_ServedDirectly(t *testing.T) { root := t.TempDir() pathFile := filepath.Join(root, "local.html") body := []byte("local archive bytes") if err := os.WriteFile(pathFile, body, 0o644); err != nil { t.Fatal(err) } cache, _ := NewCache(filepath.Join(root, CacheDirName)) f := NewFetcher(cache, nil) srv := NewServer(root, cache, f, "test") chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{ {Apps: map[string]string{"archive": "./local.html"}}, }} rec := httptest.NewRecorder() srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } if rec.Body.String() != string(body) { t.Errorf("body mismatch") } if !strings.HasPrefix(rec.Header().Get("X-ZDDC-Source"), "path:") { t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source")) } } func TestServer_FetchFailFallsBackToEmbedded(t *testing.T) { srv, _, root := newTestServer(t, []byte("ok")) saved := embeddedArchive embeddedArchive = []byte("EMBEDDED") defer func() { embeddedArchive = saved }() chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ Apps: map[string]string{"archive": "https://no-such.example/archive.html"}, }}} rec := httptest.NewRecorder() srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root) if rec.Code != http.StatusOK { t.Fatalf("status=%d (want 200 from embedded)", rec.Code) } if !strings.Contains(rec.Body.String(), "EMBEDDED") { t.Errorf("body did not come from embedded fallback: %q", rec.Body.String()) } } // ── ?v= per-request override ───────────────────────────────────────────── func TestServer_VParam_CacheHitServesFromCache(t *testing.T) { srv, _, root := newTestServer(t, []byte("ignored")) // Pre-populate the cache with a known URL. cachedURL := "https://zddc.varasys.io/releases/archive_beta.html" cachedBody := []byte("CACHED beta archive") if err := srv.Cache.Write(cachedURL, cachedBody); err != nil { t.Fatal(err) } chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} rec := httptest.NewRecorder() srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=beta", nil), "archive", chain, root) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if rec.Body.String() != string(cachedBody) { t.Errorf("body=%q, want CACHED bytes", rec.Body.String()) } if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL { t.Errorf("X-ZDDC-Source=%q", got) } } func TestServer_VParam_CacheMissReturns404(t *testing.T) { srv, _, root := newTestServer(t, []byte("ignored")) chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} rec := httptest.NewRecorder() srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=beta", nil), "archive", chain, root) if rec.Code != http.StatusNotFound { t.Fatalf("status=%d (want 404)", rec.Code) } if !strings.Contains(rec.Body.String(), "not in the local cache") { t.Errorf("body should explain cache miss, got %q", rec.Body.String()) } } func TestServer_VParam_RejectsPathSource(t *testing.T) { srv, _, root := newTestServer(t, []byte("ignored")) chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} rec := httptest.NewRecorder() srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=./local.html", nil), "archive", chain, root) if rec.Code != http.StatusBadRequest { t.Errorf("status=%d (want 400 for path source via ?v=)", rec.Code) } } func TestServer_VParam_BadSpecReturns400(t *testing.T) { srv, _, root := newTestServer(t, []byte("ignored")) chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} rec := httptest.NewRecorder() srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=not%20a%20spec", nil), "archive", chain, root) if rec.Code != http.StatusBadRequest { t.Errorf("status=%d (want 400)", rec.Code) } } func TestServer_VParam_CombinesWithCascadeURLPrefix(t *testing.T) { // Cascade has a default URL prefix; ?v=:beta should resolve against it. srv, _, root := newTestServer(t, []byte("ignored")) cachedURL := "https://my-mirror.example/releases/archive_beta.html" if err := srv.Cache.Write(cachedURL, []byte("MIRROR beta")); err != nil { t.Fatal(err) } chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ Apps: map[string]string{"default": "https://my-mirror.example/releases:stable"}, }}} rec := httptest.NewRecorder() srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=:beta", nil), "archive", chain, root) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if rec.Body.String() != "MIRROR beta" { t.Errorf("body=%q", rec.Body.String()) } if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL { t.Errorf("X-ZDDC-Source=%q (expected mirror URL)", got) } } func TestServer_VParam_OverridesPathTerminalFromCascade(t *testing.T) { // Operator's cascade specifies a path source. User passes ?v=stable. // ?v= overrides → resolves to canonical/archive_stable.html, then cache check. srv, _, root := newTestServer(t, []byte("ignored")) cachedURL := "https://zddc.varasys.io/releases/archive_stable.html" if err := srv.Cache.Write(cachedURL, []byte("CACHED stable")); err != nil { t.Fatal(err) } pathFile := filepath.Join(root, "operator-version.html") if err := os.WriteFile(pathFile, []byte("OPERATOR PATH"), 0o644); err != nil { t.Fatal(err) } chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ Apps: map[string]string{"archive": "./operator-version.html"}, }}} rec := httptest.NewRecorder() srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=stable", nil), "archive", chain, root) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if rec.Body.String() != "CACHED stable" { t.Errorf("body=%q (expected ?v= override to win)", rec.Body.String()) } } func TestServer_VParam_FullURLForm(t *testing.T) { // `?v=https://my-fork/archive.html` — terminal full URL, must be cached. srv, _, root := newTestServer(t, []byte("ignored")) cachedURL := "https://my-fork.example/custom.html" if err := srv.Cache.Write(cachedURL, []byte("FORK custom")); err != nil { t.Fatal(err) } chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} target := "/archive.html?v=" + url.QueryEscape(cachedURL) rec := httptest.NewRecorder() srv.Serve(rec, httptest.NewRequest(http.MethodGet, target, nil), "archive", chain, root) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if rec.Body.String() != "FORK custom" { t.Errorf("body=%q", rec.Body.String()) } } // TestServer_Embedded_ConditionalGET verifies the ETag/If-None-Match dance // for the embedded fallback path: a fresh GET returns 200 with an ETag, // and a follow-up with a matching If-None-Match returns 304 + empty body. // This is the cache-friendliness fix that lets a browser revalidate // against zddc-server's embedded HTML without re-transferring the bytes. func TestServer_Embedded_ConditionalGET(t *testing.T) { srv, _, root := newTestServer(t, []byte("upstream")) saved := embeddedArchive embeddedArchive = []byte("EMBEDDED archive bytes for ETag test") defer func() { embeddedArchive = saved etagCacheByApp.Delete("archive") // reset memoization for sibling tests }() etagCacheByApp.Delete("archive") // ensure clean state for THIS test chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} // First request: full body + ETag header. rec1 := httptest.NewRecorder() srv.Serve(rec1, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root) if rec1.Code != http.StatusOK { t.Fatalf("first GET: status=%d body=%s", rec1.Code, rec1.Body.String()) } etag := rec1.Header().Get("ETag") if etag == "" { t.Fatalf("first GET: missing ETag header") } if cc := rec1.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age=0") || !strings.Contains(cc, "must-revalidate") { t.Errorf("first GET: Cache-Control=%q (want max-age=0 + must-revalidate)", cc) } if !strings.Contains(rec1.Body.String(), "EMBEDDED archive bytes") { t.Errorf("first GET: body=%q", rec1.Body.String()) } // Second request with matching If-None-Match: 304, empty body. rec2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodGet, "/archive.html", nil) req2.Header.Set("If-None-Match", etag) srv.Serve(rec2, req2, "archive", chain, root) if rec2.Code != http.StatusNotModified { t.Fatalf("If-None-Match match: status=%d (want 304)", rec2.Code) } if rec2.Body.Len() != 0 { t.Errorf("304 response should have empty body; got %d bytes", rec2.Body.Len()) } // Third request with stale If-None-Match: 200, full body. rec3 := httptest.NewRecorder() req3 := httptest.NewRequest(http.MethodGet, "/archive.html", nil) req3.Header.Set("If-None-Match", `"deadbeef"`) srv.Serve(rec3, req3, "archive", chain, root) if rec3.Code != http.StatusOK { t.Errorf("stale If-None-Match: status=%d (want 200)", rec3.Code) } if rec3.Body.Len() == 0 { t.Errorf("stale If-None-Match: empty body; want full") } } // TestEmbeddedETag_Stable asserts EmbeddedETag is deterministic and // content-addressed: same bytes → same ETag, different bytes → different. func TestEmbeddedETag_Stable(t *testing.T) { saved := embeddedArchive defer func() { embeddedArchive = saved etagCacheByApp.Delete("archive") }() embeddedArchive = []byte("alpha") etagCacheByApp.Delete("archive") a1 := EmbeddedETag("archive") a2 := EmbeddedETag("archive") if a1 == "" || a1 != a2 { t.Errorf("EmbeddedETag should be stable for same bytes; got %q vs %q", a1, a2) } embeddedArchive = []byte("beta") etagCacheByApp.Delete("archive") b := EmbeddedETag("archive") if b == a1 { t.Errorf("EmbeddedETag should differ for different bytes; both %q", b) } }