Releases publish only two things per tool now: a current-stable
canonical symlink and an immutable per-version file. No more channel
mirrors (_stable/_beta/_alpha) and no more partial-version pins
(_v<X.Y>, _v<X>) — those were debt from a release model that never
matched the project's actual usage.
The `./build beta` verb stays, but narrowed: it's an internal SHA
snapshot for the BMC dev chart pipeline (chart's appVersion pins to
"<X.Y.Z>-beta-<sha>" and the chart Dockerfile fetches that SHA from
git). No public artifact on /srv/zddc/releases/. The embedded/* +
chore commit produced by `./build beta` is the actual snapshot.
`./build alpha` is removed entirely.
build/build-lib.sh:
- Drop alpha verb; narrow beta verb to embedded regen + chore commit
- promote_release: stable cut writes <tool>_v<X.Y.Z>.html + <tool>.html
symlink + <tool>.html.sig companion symlink; beta is a no-op
- promote_zddc_server: same shape — per-version binary +
per-platform canonical symlink (zddc-server_<plat>) + .sig symlink
- write_zddc_server_stub: singular; emits per-version stubs +
one canonical zddc-server.html for current stable
- Delete _promote_channel, verify_channel_links, _channel_is_active
- Seed-from-live now copies only per-version files + .sig + pubkey.pem
(the canonical symlinks get rewritten by this cut; old layout files
get cleaned by deploy's --delete-after)
- build_releases_index: dropdown simplified to "latest stable +
pinned versions"; channels-explainer section removed; tool cards +
CTA URLs point at canonical <tool>.html / zddc-server_<plat>;
composer emits "stable" sentinel for `apps:` entries
- Fix the acl:{allow:[...]} footgun in the apps_pubkey example
apps.go:
- isValidChannelOrVersion: accept only "stable" + exact X.Y.Z
(drop alpha/beta and partial pins v0.0/v0)
- normalizeChannel: same
- Resolve URL composition: stable → canonical <prefix>/<app>.html
(no _stable_ suffix), exact-version → <prefix>/<app>_v<X.Y.Z>.html
- Tests rewritten to match (beta/alpha replaced with v0.0.4 / stable;
a new TestParseSpec_RejectsLegacyChannelsAndPartialPins locks in
that the removed forms now error)
browse/build.sh: gate promote_release on $is_release like every other
tool's build.sh (longstanding inconsistency that errored under the new
promote_release case-statement).
freshen-channel: deleted (no channels to freshen).
Net: -254 lines, all green on full `go test ./...`. Dev build verified
via `./build` (no-arg) — new label format "v<next>-dev · <ts> · <sha>".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
427 lines
15 KiB
Go
427 lines
15 KiB
Go
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/browse.html", "browse", "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_v0.0.4.html"
|
|
cachedBody := []byte("CACHED v0.0.4 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=v0.0.4", 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=v0.0.4", 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_v0.0.4.html"
|
|
if err := srv.Cache.Write(cachedURL, []byte("MIRROR v0.0.4")); 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=:v0.0.4", 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 v0.0.4" {
|
|
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.html, then cache check.
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
cachedURL := "https://zddc.varasys.io/releases/archive.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)
|
|
}
|
|
}
|