| index 3bd4188ce..6b225f932 100644 |
| --- a/internal/edge/static_test.go |
| +++ b/internal/edge/static_test.go |
| @@ -4,12 +4,14 @@ import ( |
| "bytes" |
| "context" |
| "crypto/ed25519" |
| + "errors" |
| "io" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| "path/filepath" |
| "strings" |
| + "sync" |
| "testing" |
| |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -37,6 +39,24 @@ func getPath(t *testing.T, base, host, path string) (int, string) { |
| return resp.StatusCode, string(b) |
| } |
| |
| +// getPathResp is getPath's sibling for tests that need the response headers |
| +// rather than the body — X-Carlos-Version lives there, not in the tree. |
| +func getPathResp(t *testing.T, base, host, path string) *http.Response { |
| + t.Helper() |
| + req, err := http.NewRequest("GET", base+path, nil) |
| + if err != nil { |
| + t.Fatalf("new request: %v", err) |
| + } |
| + req.Host = host |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("GET %s%s (host %s): %v", base, path, host, err) |
| + } |
| + defer resp.Body.Close() |
| + io.Copy(io.Discard, resp.Body) |
| + return resp |
| +} |
| + |
| func testKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) { |
| t.Helper() |
| seedB64, pubB64, err := release.GenerateKey() |
| @@ -235,6 +255,148 @@ func TestEdgeServesStaticRouteWhenEnabled(t *testing.T) { |
| } |
| } |
| |
| +// --- X-Carlos-Version for static routes (issue #112) -------------------- |
| +// |
| +// A static route is never adopted, so registry.Route.Version is always empty |
| +// and edge.go's versionHeader wrapper never applies. The handler stamps its |
| +// own — the version whose tree answered the request. |
| + |
| +// TestStaticStampsTheVersionItServes: every response out of the site tree |
| +// carries the version that tree is, including the tree's own 404s, and the |
| +// stamp follows a pointer advance. |
| +func TestStaticStampsTheVersionItServes(t *testing.T) { |
| + store, _, priv, srv := staticFixture(t) |
| + |
| + resp := getPathResp(t, srv.URL, "site.example.com", "/") |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("GET / = %d, want 200", resp.StatusCode) |
| + } |
| + if got := resp.Header.Get("X-Carlos-Version"); got != "v1" { |
| + t.Fatalf("X-Carlos-Version = %q, want %q", got, "v1") |
| + } |
| + |
| + // A 404 from inside the tree is still an answer from a version — the |
| + // deploy watch polls "/" and a site whose root is a 404 must still be |
| + // diagnosable as "serving v1, but HTTP 404" rather than as an old edge. |
| + resp = getPathResp(t, srv.URL, "site.example.com", "/missing.html") |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("GET /missing.html = %d, want 404", resp.StatusCode) |
| + } |
| + if got := resp.Header.Get("X-Carlos-Version"); got != "v1" { |
| + t.Fatalf("404 X-Carlos-Version = %q, want %q", got, "v1") |
| + } |
| + |
| + shipSite(t, store, "site", "v2", map[string]string{"index.html": "home v2"}) |
| + if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil { |
| + t.Fatalf("Promote v2: %v", err) |
| + } |
| + resp = getPathResp(t, srv.URL, "site.example.com", "/") |
| + if got := resp.Header.Get("X-Carlos-Version"); got != "v2" { |
| + t.Fatalf("post-advance X-Carlos-Version = %q, want %q", got, "v2") |
| + } |
| +} |
| + |
| +// brokenStore reads through to a real store until breakNow, then fails every |
| +// read — the shape that drives staticSite down its degraded path (an |
| +// unreadable channel pointer) after it has already resolved and extracted. |
| +type brokenStore struct { |
| + release.Store |
| + mu sync.Mutex |
| + broken bool |
| +} |
| + |
| +func (b *brokenStore) breakNow() { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + b.broken = true |
| +} |
| + |
| +func (b *brokenStore) failing() bool { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return b.broken |
| +} |
| + |
| +func (b *brokenStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if b.failing() { |
| + return nil, "", errors.New("store unreachable") |
| + } |
| + return b.Store.Get(ctx, key) |
| +} |
| + |
| +func (b *brokenStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + if b.failing() { |
| + return nil, errors.New("store unreachable") |
| + } |
| + return b.Store.List(ctx, prefix) |
| +} |
| + |
| +// TestStaticDegradedStampsTheCachedVersion: when the pointer goes unreadable |
| +// the handler keeps serving the version it already verified — and must stamp |
| +// THAT, never the head it could not reach. A watch that saw the new version |
| +// on a response whose bytes are the old one would be exactly the false |
| +// "verified" this header exists to prevent. |
| +func TestStaticDegradedStampsTheCachedVersion(t *testing.T) { |
| + ctx := context.Background() |
| + dir := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + priv, pub := testKeys(t) |
| + shipSite(t, dir, "site", "v1", map[string]string{"index.html": "home v1"}) |
| + if err := release.Promote(ctx, dir, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote v1: %v", err) |
| + } |
| + |
| + bs := &brokenStore{Store: dir} |
| + rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"} |
| + h := newStaticHandler(rt, bs, pub, filepath.Join(t.TempDir(), "cache")) |
| + h.(*staticSite).recheck = 0 // re-check the pointer on every request |
| + srv := httptest.NewServer(h) |
| + defer srv.Close() |
| + |
| + if got := getPathResp(t, srv.URL, "site.example.com", "/").Header.Get("X-Carlos-Version"); got != "v1" { |
| + t.Fatalf("pre-break X-Carlos-Version = %q, want %q", got, "v1") |
| + } |
| + |
| + // The pointer advances to v2 and the store goes away in the same breath: |
| + // the head is v2, the bytes on disk are v1. |
| + shipSite(t, dir, "site", "v2", map[string]string{"index.html": "home v2"}) |
| + if err := release.Promote(ctx, dir, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil { |
| + t.Fatalf("Promote v2: %v", err) |
| + } |
| + bs.breakNow() |
| + |
| + code, body := getPath(t, srv.URL, "site.example.com", "/") |
| + if code != http.StatusOK || body != "home v1" { |
| + t.Fatalf("degraded GET / = %d %q, want 200 %q", code, body, "home v1") |
| + } |
| + if got := getPathResp(t, srv.URL, "site.example.com", "/").Header.Get("X-Carlos-Version"); got != "v1" { |
| + t.Fatalf("degraded X-Carlos-Version = %q, want %q (the CACHED version, not the unreachable head)", got, "v1") |
| + } |
| +} |
| + |
| +// TestStaticUnconfiguredHasNoVersionHeader: the pre-EnableStatic 404 is not a |
| +// version — the route exists (so its cert can be minted) before any bytes can |
| +// flow, and there is nothing to name. |
| +func TestStaticUnconfiguredHasNoVersionHeader(t *testing.T) { |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + resp := getHostHeader(t, srv.URL, "site.example.com") |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("unconfigured GET = %d, want 404", resp.StatusCode) |
| + } |
| + if got, ok := resp.Header["X-Carlos-Version"]; ok { |
| + t.Fatalf("X-Carlos-Version present (%v) on the unconfigured 404 — no version exists there", got) |
| + } |
| +} |
| + |
| // legacyTripwire fails the test if the edge asks the store for anything under |
| // one app's LEGACY unqualified prefix. The release package has its own; the |
| // edge needs one of its own too, because a tripwire only guards the paths a |