| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "io" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "slices" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | func siteDirOf(t *testing.T, store release.Store, app, version string) string { |
| 20 | t.Helper() |
| 21 | raw, _, err := store.Get(context.Background(), release.ManifestKey(release.OpsAccount, app, version)) |
| 22 | if err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | return siteDirName(version, release.ManifestDigest(raw)) |
| 26 | } |
| 27 | |
| 28 | func digestSite(t *testing.T, ratchet *release.DigestRatchet) (release.Store, *staticSite, *httptest.Server, func(string, map[string]string)) { |
| 29 | t.Helper() |
| 30 | store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| 31 | priv, pub := testKeys(t) |
| 32 | if err := release.WriteDigestMarker(context.Background(), store, release.RootSigner(priv), release.OpsAccount, "test"); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | promote := func(v string, files map[string]string) { |
| 36 | shipSite(t, store, "site", v, files) |
| 37 | if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", v); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | } |
| 41 | promote("v1", map[string]string{"index.html": "home v1"}) |
| 42 | rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"} |
| 43 | h := newStaticHandler(rt, store, pub, filepath.Join(t.TempDir(), "cache")).(*staticSite) |
| 44 | h.recheck = 0 |
| 45 | h.ratchet = ratchet |
| 46 | srv := httptest.NewServer(h) |
| 47 | t.Cleanup(srv.Close) |
| 48 | return store, h, srv, promote |
| 49 | } |
| 50 | |
| 51 | func getBody(t *testing.T, url string) (string, string) { |
| 52 | t.Helper() |
| 53 | res, err := http.Get(url) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | defer res.Body.Close() |
| 58 | b, _ := io.ReadAll(res.Body) |
| 59 | return string(b), res.Header.Get("X-Carlos-Version") |
| 60 | } |
| 61 | |
| 62 | // Spec §8: a static site whose version name is reused, under a pointer with |
| 63 | // digests, is refused before extraction. |
| 64 | func TestStaticRefusesAReusedNameBeforeExtraction(t *testing.T) { |
| 65 | store, h, srv, promote := digestSite(t, nil) |
| 66 | if b, v := getBody(t, srv.URL+"/"); b != "home v1" || v != "v1" { |
| 67 | t.Fatalf("first serve = %q %q", b, v) |
| 68 | } |
| 69 | saved, _, _ := store.Get(context.Background(), release.ChannelKey(release.OpsAccount, "site", "canary/x")) |
| 70 | promote("v2", map[string]string{"index.html": "home v2"}) |
| 71 | if b, _ := getBody(t, srv.URL+"/"); b != "home v2" { |
| 72 | t.Fatalf("v2 not served: %q", b) |
| 73 | } |
| 74 | if err := release.DeleteVersion(context.Background(), store, release.OpsAccount, "site", "v1"); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | shipSite(t, store, "site", "v1", map[string]string{"index.html": "planted"}) |
| 78 | _, etag, _ := store.Get(context.Background(), release.ChannelKey(release.OpsAccount, "site", "canary/x")) |
| 79 | if _, err := store.Put(context.Background(), release.ChannelKey(release.OpsAccount, "site", "canary/x"), saved, etag); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | if b, v := getBody(t, srv.URL+"/"); b == "planted" || v != "v2" { |
| 83 | t.Fatalf("after the replay = %q %q; want v2 still served", b, v) |
| 84 | } |
| 85 | if _, err := os.Stat(filepath.Join(h.appDir(), siteDirOf(t, store, "site", "v1"))); err == nil { |
| 86 | t.Fatal("the reused name's archive was extracted") |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // Spec §8: a cached tree from other bytes is not served. |
| 91 | func TestStaticNeverServesACachedTreeFromOtherBytes(t *testing.T) { |
| 92 | store, h, srv, _ := digestSite(t, nil) |
| 93 | other := filepath.Join(h.appDir(), siteDirName("v1", release.ManifestDigest([]byte("other manifest")))) |
| 94 | if err := os.MkdirAll(other, 0o755); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | if err := os.WriteFile(filepath.Join(other, "index.html"), []byte("stale tree"), 0o644); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | if b, _ := getBody(t, srv.URL+"/"); b != "home v1" { |
| 101 | t.Fatalf("served %q from a tree keyed by another digest", b) |
| 102 | } |
| 103 | if _, err := os.Stat(filepath.Join(h.appDir(), siteDirOf(t, store, "site", "v1"), "index.html")); err != nil { |
| 104 | t.Fatalf("not extracted under its own digest: %v", err) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // Review Focus 5. |
| 109 | func TestStaticNeverReusesALegacyCacheDir(t *testing.T) { |
| 110 | _, h, srv, _ := digestSite(t, nil) |
| 111 | legacy := filepath.Join(h.appDir(), "v1") |
| 112 | if err := os.MkdirAll(legacy, 0o755); err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | if err := os.WriteFile(filepath.Join(legacy, "index.html"), []byte("pre-upgrade tree"), 0o644); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | if b, _ := getBody(t, srv.URL+"/"); b != "home v1" { |
| 119 | t.Fatalf("served %q from the bare-version directory", b) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func TestStaticRatchetRefusesALegacyPointerAfterDigests(t *testing.T) { |
| 124 | r := release.NewDigestRatchet(t.TempDir(), nil, false) |
| 125 | _, _, srv, _ := digestSite(t, r) |
| 126 | if _, v := getBody(t, srv.URL+"/"); v != "v1" { |
| 127 | t.Fatalf("version %q", v) |
| 128 | } |
| 129 | if err := r.Admit(release.OpsAccount, &release.ChannelPointer{App: "site", Channel: "canary/x", Version: "v1"}); err == nil { |
| 130 | t.Fatal("the edge's ratchet did not arm on the digest-bearing pointer it served") |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func TestStaticWithoutDigestsServesAsBefore(t *testing.T) { |
| 135 | _, _, _, srv := staticFixture(t) |
| 136 | if b, v := getBody(t, srv.URL+"/"); b != "home v1" || v != "v1" { |
| 137 | t.Fatalf("legacy site = %q %q", b, v) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // prodStaticEdge is an Edge whose static serving is configured the way the |
| 142 | // edge process configures it, through enableStaticFromEnv: the store and key |
| 143 | // from the environment, and the ratchet built there from dataDir and |
| 144 | // CARLOS_OPT_DIR (Astra r1 #6). The pointer cache is off, so every request |
| 145 | // re-reads the pointer. |
| 146 | type prodStaticEdge struct { |
| 147 | store release.Store |
| 148 | priv ed25519.PrivateKey |
| 149 | data string |
| 150 | srv *httptest.Server |
| 151 | } |
| 152 | |
| 153 | func newProdStaticEdge(t *testing.T, require bool, seed func(release.Store, ed25519.PrivateKey)) *prodStaticEdge { |
| 154 | t.Helper() |
| 155 | storeDir := filepath.Join(t.TempDir(), "store") |
| 156 | store := release.NewDirStore(storeDir) |
| 157 | seedB64, pubB64, err := release.GenerateKey() |
| 158 | if err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | priv, err := release.ParseSigningKey(seedB64, "test") |
| 162 | if err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | seed(store, priv) |
| 166 | t.Setenv("CARLOS_RELEASE_PUBKEY", pubB64) |
| 167 | t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| 168 | t.Setenv("CARLOS_DEPLOYMENT_DIR", storeDir) |
| 169 | t.Setenv("CARLOS_OPT_DIR", t.TempDir()) |
| 170 | flag := "" |
| 171 | if require { |
| 172 | flag = "1" |
| 173 | } |
| 174 | t.Setenv(release.RequirePointerDigestEnv, flag) |
| 175 | reg := openReg(t) |
| 176 | if err := reg.Upsert(registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}); err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | e, err := New(reg, nil) |
| 180 | if err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | data := t.TempDir() |
| 184 | if err := enableStaticFromEnv(e, data); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | e.SetStaticRecheck(0) |
| 188 | if err := e.Reload(); err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | srv := httptest.NewServer(e) |
| 192 | t.Cleanup(srv.Close) |
| 193 | return &prodStaticEdge{store: store, priv: priv, data: data, srv: srv} |
| 194 | } |
| 195 | |
| 196 | func (p *prodStaticEdge) get(t *testing.T) (int, string, string) { |
| 197 | t.Helper() |
| 198 | req, err := http.NewRequest("GET", p.srv.URL+"/", nil) |
| 199 | if err != nil { |
| 200 | t.Fatal(err) |
| 201 | } |
| 202 | req.Host = "site.example.com" |
| 203 | res, err := http.DefaultClient.Do(req) |
| 204 | if err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | defer res.Body.Close() |
| 208 | b, _ := io.ReadAll(res.Body) |
| 209 | return res.StatusCode, string(b), res.Header.Get("X-Carlos-Version") |
| 210 | } |
| 211 | |
| 212 | // extracted lists the site trees under the edge's static cache. |
| 213 | func (p *prodStaticEdge) extracted(t *testing.T) []string { |
| 214 | t.Helper() |
| 215 | entries, err := os.ReadDir(filepath.Join(p.data, "static", release.OpsAccount, "site")) |
| 216 | if err != nil && !os.IsNotExist(err) { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | var out []string |
| 220 | for _, e := range entries { |
| 221 | if e.IsDir() && !strings.HasPrefix(e.Name(), ".") { |
| 222 | out = append(out, e.Name()) |
| 223 | } |
| 224 | } |
| 225 | return out |
| 226 | } |
| 227 | |
| 228 | func (p *prodStaticEdge) promote(t *testing.T, v, page string) { |
| 229 | t.Helper() |
| 230 | shipSite(t, p.store, "site", v, map[string]string{"index.html": page}) |
| 231 | if err := release.Promote(context.Background(), p.store, p.priv, release.OpsAccount, "site", "canary/x", v); err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | // Astra r1 #6: the static handler, configured as production configures it, |
| 237 | // refuses a replayed legacy pointer once it has served a pointer with |
| 238 | // digests. The saved v1 pointer comes back with v1's name reshipped under |
| 239 | // other bytes; the edge keeps serving v2 and extracts nothing new. The |
| 240 | // control is the same legacy pointer served on first sight. |
| 241 | func TestEdgeStaticRefusesAReplayedLegacyPointer(t *testing.T) { |
| 242 | p := newProdStaticEdge(t, false, func(release.Store, ed25519.PrivateKey) {}) |
| 243 | ctx := context.Background() |
| 244 | p.promote(t, "v1", "home v1") // no marker: a legacy pointer |
| 245 | key := release.ChannelKey(release.OpsAccount, "site", "canary/x") |
| 246 | saved, _, err := p.store.Get(ctx, key) |
| 247 | if err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | if code, b, v := p.get(t); code != http.StatusOK || b != "home v1" || v != "v1" { |
| 251 | t.Fatalf("control: the legacy pointer on first sight = %d %q %q", code, b, v) |
| 252 | } |
| 253 | if err := release.WriteDigestMarker(ctx, p.store, release.RootSigner(p.priv), release.OpsAccount, "test"); err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | p.promote(t, "v2", "home v2") // with digests: the edge marks the channel |
| 257 | if code, b, v := p.get(t); code != http.StatusOK || b != "home v2" || v != "v2" { |
| 258 | t.Fatalf("the digest pointer = %d %q %q", code, b, v) |
| 259 | } |
| 260 | before := p.extracted(t) |
| 261 | if err := release.DeleteVersion(ctx, p.store, release.OpsAccount, "site", "v1"); err != nil { |
| 262 | t.Fatal(err) |
| 263 | } |
| 264 | shipSite(t, p.store, "site", "v1", map[string]string{"index.html": "planted"}) |
| 265 | _, etag, _ := p.store.Get(ctx, key) |
| 266 | if _, err := p.store.Put(ctx, key, saved, etag); err != nil { |
| 267 | t.Fatal(err) |
| 268 | } |
| 269 | if code, b, v := p.get(t); code != http.StatusOK || b != "home v2" || v != "v2" { |
| 270 | t.Fatalf("after the replay = %d %q %q; want v2 still served", code, b, v) |
| 271 | } |
| 272 | if after := p.extracted(t); !slices.Equal(before, after) { |
| 273 | t.Fatalf("the replay extracted a tree: before %v, after %v", before, after) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | // CARLOS_REQUIRE_POINTER_DIGEST through the edge's own ratchet construction: |
| 278 | // a fresh box with the flag on refuses a legacy pointer for a channel it has |
| 279 | // never seen, so nothing is extracted or served; with the flag off (the |
| 280 | // control) the same pointer serves. |
| 281 | func TestEdgeStaticRequireFlagOnAFreshBox(t *testing.T) { |
| 282 | for _, on := range []bool{false, true} { |
| 283 | p := newProdStaticEdge(t, on, func(s release.Store, priv ed25519.PrivateKey) { |
| 284 | shipSite(t, s, "site", "v1", map[string]string{"index.html": "home v1"}) |
| 285 | if err := release.Promote(context.Background(), s, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil { |
| 286 | t.Fatal(err) |
| 287 | } |
| 288 | }) |
| 289 | code, b, _ := p.get(t) |
| 290 | switch { |
| 291 | case on && (code == http.StatusOK || len(p.extracted(t)) != 0): |
| 292 | t.Fatalf("flag on: a fresh box served a legacy pointer: %d %q, extracted %v", code, b, p.extracted(t)) |
| 293 | case !on && (code != http.StatusOK || b != "home v1"): |
| 294 | t.Fatalf("control, flag off: %d %q, want home v1", code, b) |
| 295 | } |
| 296 | } |
| 297 | } |
| 298 | |